csv-stats 0.2.0__tar.gz → 0.2.1__tar.gz
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.
- {csv_stats-0.2.0 → csv_stats-0.2.1}/PKG-INFO +1 -1
- {csv_stats-0.2.0 → csv_stats-0.2.1}/pyproject.toml +8 -2
- csv_stats-0.2.1/tests/test_anova1way.py +239 -0
- csv_stats-0.2.1/tests/test_anova2way.py +371 -0
- csv_stats-0.2.1/tests/test_core.py +288 -0
- csv_stats-0.2.1/tests/test_descriptive.py +116 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1}/.gitignore +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1}/README.md +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/__init__.py +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/anova.py +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/core.py +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/descriptive.py +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/ttest.py +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/utils/__init__.py +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/utils/load_data.py +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/utils/plot_data.py +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/utils/plot_helper.py +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/utils/run_all_columns.py +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/utils/save_stats.py +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/utils/summary_stats.py +0 -0
- {csv_stats-0.2.0 → csv_stats-0.2.1/src}/csvstats/utils/test_assumptions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: csv-stats
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.1
|
|
4
4
|
Summary: A Python package for the life sciences to conduct hypothesis testing from CSV files.
|
|
5
5
|
Project-URL: Homepage, https://github.com/mtillman14/csv-stats
|
|
6
6
|
Project-URL: Documentation, https://github.com/mtillman14/csv-stats
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "csv-stats"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "A Python package for the life sciences to conduct hypothesis testing from CSV files."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -53,5 +53,11 @@ Issues = "https://github.com/mtillman14/csv-stats/issues"
|
|
|
53
53
|
[tool.hatch.build.targets.wheel]
|
|
54
54
|
packages = ["src/csvstats"]
|
|
55
55
|
|
|
56
|
+
# The sdist keeps the src/ layout: the wheel is built FROM the sdist, and
|
|
57
|
+
# looks for src/csvstats. `packages` here re-rooted it to csvstats/, so the
|
|
58
|
+
# 0.2.0 wheel came out empty (metadata only).
|
|
56
59
|
[tool.hatch.build.targets.sdist]
|
|
57
|
-
|
|
60
|
+
only-include = ["src/csvstats", "tests", "README.md"]
|
|
61
|
+
|
|
62
|
+
[tool.hatch.build]
|
|
63
|
+
ignore-vcs = true
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
|
|
2
|
+
from csvstats.anova import anova1way
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_anova1way_independent_significant():
|
|
8
|
+
"""Test independent samples one-way ANOVA with a significant result."""
|
|
9
|
+
|
|
10
|
+
np.random.seed(0)
|
|
11
|
+
group_A = np.random.normal(loc=5.0, scale=1.0, size=30)
|
|
12
|
+
group_B = np.random.normal(loc=8.0, scale=1.0, size=30)
|
|
13
|
+
data = pd.DataFrame({
|
|
14
|
+
'group': np.array(['A'] * 30 + ['B'] * 30),
|
|
15
|
+
'value': np.concatenate([group_A, group_B]),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
result = anova1way(data, group_column='group', data_column='value', filename=None)
|
|
19
|
+
|
|
20
|
+
assert result is not None
|
|
21
|
+
assert result["test"] == "One-way Independent Samples ANOVA"
|
|
22
|
+
assert "date" in result
|
|
23
|
+
assert result["group_column"] == "group"
|
|
24
|
+
assert result["data_column"] == "value"
|
|
25
|
+
assert result["repeated_measures_column"] == ""
|
|
26
|
+
|
|
27
|
+
# With a large mean difference, the result should be significant
|
|
28
|
+
assert "F" in result
|
|
29
|
+
assert "p" in result
|
|
30
|
+
assert result["p"] < 0.05
|
|
31
|
+
|
|
32
|
+
# Degrees of freedom
|
|
33
|
+
assert result["df_between"] == 1 # 2 groups - 1
|
|
34
|
+
assert result["df_within"] == 58 # 60 observations - 2 groups
|
|
35
|
+
|
|
36
|
+
# Summary statistics
|
|
37
|
+
assert "summary_statistics" in result
|
|
38
|
+
|
|
39
|
+
# Assumption tests
|
|
40
|
+
assert "normality_test" in result
|
|
41
|
+
assert "homogeneity_of_variance_test" in result
|
|
42
|
+
assert result["homogeneity_of_variance_test"] != "Not applicable"
|
|
43
|
+
assert result["sphericity_test"] == "Not applicable"
|
|
44
|
+
|
|
45
|
+
# Post-hoc should be performed for significant result
|
|
46
|
+
assert "post_hoc" in result
|
|
47
|
+
assert result["post_hoc"] != "Not applicable"
|
|
48
|
+
assert "correction_method" in result["post_hoc"]
|
|
49
|
+
assert "significant_pairs" in result["post_hoc"]
|
|
50
|
+
assert "posthoc_results" in result["post_hoc"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_anova1way_independent_nonsignificant():
|
|
54
|
+
"""Test independent samples one-way ANOVA with a non-significant result."""
|
|
55
|
+
|
|
56
|
+
np.random.seed(42)
|
|
57
|
+
# Same mean for both groups -> non-significant
|
|
58
|
+
group_A = np.random.normal(loc=5.0, scale=1.0, size=30)
|
|
59
|
+
group_B = np.random.normal(loc=5.0, scale=1.0, size=30)
|
|
60
|
+
data = pd.DataFrame({
|
|
61
|
+
'group': np.array(['A'] * 30 + ['B'] * 30),
|
|
62
|
+
'value': np.concatenate([group_A, group_B]),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
result = anova1way(data, group_column='group', data_column='value', filename=None)
|
|
66
|
+
|
|
67
|
+
assert result is not None
|
|
68
|
+
assert result["test"] == "One-way Independent Samples ANOVA"
|
|
69
|
+
assert result["p"] >= 0.05
|
|
70
|
+
assert result["post_hoc"] == "Not applicable"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_anova1way_independent_three_groups():
|
|
74
|
+
"""Test independent samples one-way ANOVA with three groups."""
|
|
75
|
+
|
|
76
|
+
np.random.seed(10)
|
|
77
|
+
n = 25
|
|
78
|
+
data = pd.DataFrame({
|
|
79
|
+
'group': np.repeat(['A', 'B', 'C'], n),
|
|
80
|
+
'value': np.concatenate([
|
|
81
|
+
np.random.normal(loc=5.0, scale=1.0, size=n),
|
|
82
|
+
np.random.normal(loc=8.0, scale=1.0, size=n),
|
|
83
|
+
np.random.normal(loc=11.0, scale=1.0, size=n),
|
|
84
|
+
]),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
result = anova1way(data, group_column='group', data_column='value', filename=None)
|
|
88
|
+
|
|
89
|
+
assert result is not None
|
|
90
|
+
assert result["df_between"] == 2 # 3 groups - 1
|
|
91
|
+
assert result["df_within"] == 72 # 75 observations - 3 groups
|
|
92
|
+
assert result["p"] < 0.05
|
|
93
|
+
assert result["post_hoc"] != "Not applicable"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_anova1way_repeated_measures_significant():
|
|
97
|
+
"""Test repeated measures one-way ANOVA with a significant result."""
|
|
98
|
+
|
|
99
|
+
np.random.seed(5)
|
|
100
|
+
n_subjects = 20
|
|
101
|
+
|
|
102
|
+
subjects = np.tile(np.arange(n_subjects), 3)
|
|
103
|
+
condition = np.repeat(['low', 'medium', 'high'], n_subjects)
|
|
104
|
+
scores = np.concatenate([
|
|
105
|
+
np.random.normal(loc=10, scale=2, size=n_subjects),
|
|
106
|
+
np.random.normal(loc=14, scale=2, size=n_subjects),
|
|
107
|
+
np.random.normal(loc=18, scale=2, size=n_subjects),
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
data = pd.DataFrame({
|
|
111
|
+
'subject': subjects,
|
|
112
|
+
'condition': condition,
|
|
113
|
+
'score': scores,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
result = anova1way(data, group_column='condition', data_column='score',
|
|
117
|
+
repeated_measures_column='subject', filename=None)
|
|
118
|
+
|
|
119
|
+
assert result is not None
|
|
120
|
+
assert result["test"] == "One-way Repeated Measures ANOVA"
|
|
121
|
+
assert result["repeated_measures_column"] == "subject"
|
|
122
|
+
assert result["p"] < 0.05
|
|
123
|
+
|
|
124
|
+
# Assumption tests for repeated measures
|
|
125
|
+
assert "normality_test" in result
|
|
126
|
+
assert result["sphericity_test"] != "Not applicable"
|
|
127
|
+
assert result["homogeneity_of_variance_test"] == "Not applicable"
|
|
128
|
+
|
|
129
|
+
# Post-hoc should be performed
|
|
130
|
+
assert result["post_hoc"] != "Not applicable"
|
|
131
|
+
assert "correction_method" in result["post_hoc"]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_anova1way_repeated_measures_nonsignificant():
|
|
135
|
+
"""Test repeated measures one-way ANOVA with a non-significant result."""
|
|
136
|
+
|
|
137
|
+
np.random.seed(99)
|
|
138
|
+
n_subjects = 15
|
|
139
|
+
|
|
140
|
+
subjects = np.tile(np.arange(n_subjects), 3)
|
|
141
|
+
condition = np.repeat(['A', 'B', 'C'], n_subjects)
|
|
142
|
+
# Same mean across conditions -> non-significant
|
|
143
|
+
scores = np.concatenate([
|
|
144
|
+
np.random.normal(loc=10, scale=2, size=n_subjects),
|
|
145
|
+
np.random.normal(loc=10, scale=2, size=n_subjects),
|
|
146
|
+
np.random.normal(loc=10, scale=2, size=n_subjects),
|
|
147
|
+
])
|
|
148
|
+
|
|
149
|
+
data = pd.DataFrame({
|
|
150
|
+
'subject': subjects,
|
|
151
|
+
'condition': condition,
|
|
152
|
+
'score': scores,
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
result = anova1way(data, group_column='condition', data_column='score',
|
|
156
|
+
repeated_measures_column='subject', filename=None)
|
|
157
|
+
|
|
158
|
+
assert result is not None
|
|
159
|
+
assert result["test"] == "One-way Repeated Measures ANOVA"
|
|
160
|
+
assert result["p"] >= 0.05
|
|
161
|
+
assert result["post_hoc"] == "Not applicable"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_anova1way_skip_same_column_as_group():
|
|
165
|
+
"""Test that anova1way returns None when data_column matches group_column."""
|
|
166
|
+
|
|
167
|
+
data = pd.DataFrame({'group': ['A', 'B', 'C'], 'value': [1, 2, 3]})
|
|
168
|
+
result = anova1way(data, group_column='group', data_column='group', filename=None)
|
|
169
|
+
assert result is None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_anova1way_skip_same_column_as_repeated_measures():
|
|
173
|
+
"""Test that anova1way returns None when data_column matches repeated_measures_column."""
|
|
174
|
+
|
|
175
|
+
data = pd.DataFrame({'subject': [1, 2, 3], 'group': ['A', 'B', 'C'], 'value': [1, 2, 3]})
|
|
176
|
+
result = anova1way(data, group_column='group', data_column='subject',
|
|
177
|
+
repeated_measures_column='subject', filename=None)
|
|
178
|
+
assert result is None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_anova1way_none_repeated_measures_column():
|
|
182
|
+
"""Test that passing None for repeated_measures_column is handled (converted to empty string)."""
|
|
183
|
+
|
|
184
|
+
np.random.seed(0)
|
|
185
|
+
data = pd.DataFrame({
|
|
186
|
+
'group': np.array(['A'] * 30 + ['B'] * 30),
|
|
187
|
+
'value': np.concatenate([
|
|
188
|
+
np.random.normal(loc=5.0, scale=1.0, size=30),
|
|
189
|
+
np.random.normal(loc=8.0, scale=1.0, size=30),
|
|
190
|
+
]),
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
result = anova1way(data, group_column='group', data_column='value',
|
|
194
|
+
repeated_measures_column=None, filename=None)
|
|
195
|
+
|
|
196
|
+
assert result is not None
|
|
197
|
+
assert result["test"] == "One-way Independent Samples ANOVA"
|
|
198
|
+
assert result["repeated_measures_column"] == ""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def test_anova1way_all_columns():
|
|
202
|
+
"""Test the all-columns path (data_column == '_')."""
|
|
203
|
+
|
|
204
|
+
np.random.seed(3)
|
|
205
|
+
n = 20
|
|
206
|
+
data = pd.DataFrame({
|
|
207
|
+
'group': np.repeat(['A', 'B'], n),
|
|
208
|
+
'measure1': np.concatenate([
|
|
209
|
+
np.random.normal(loc=5.0, scale=1.0, size=n),
|
|
210
|
+
np.random.normal(loc=8.0, scale=1.0, size=n),
|
|
211
|
+
]),
|
|
212
|
+
'measure2': np.concatenate([
|
|
213
|
+
np.random.normal(loc=10.0, scale=1.0, size=n),
|
|
214
|
+
np.random.normal(loc=10.0, scale=1.0, size=n),
|
|
215
|
+
]),
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
results = anova1way(data, group_column='group', data_column='_', filename=None)
|
|
219
|
+
|
|
220
|
+
assert results is not None
|
|
221
|
+
assert isinstance(results, dict)
|
|
222
|
+
assert 'measure1' in results
|
|
223
|
+
assert 'measure2' in results
|
|
224
|
+
# Each sub-result should be a full anova result dict
|
|
225
|
+
assert results['measure1']['test'] == "One-way Independent Samples ANOVA"
|
|
226
|
+
assert results['measure2']['test'] == "One-way Independent Samples ANOVA"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
test_anova1way_independent_significant()
|
|
231
|
+
test_anova1way_independent_nonsignificant()
|
|
232
|
+
test_anova1way_independent_three_groups()
|
|
233
|
+
test_anova1way_repeated_measures_significant()
|
|
234
|
+
test_anova1way_repeated_measures_nonsignificant()
|
|
235
|
+
test_anova1way_skip_same_column_as_group()
|
|
236
|
+
test_anova1way_skip_same_column_as_repeated_measures()
|
|
237
|
+
test_anova1way_none_repeated_measures_column()
|
|
238
|
+
test_anova1way_all_columns()
|
|
239
|
+
print("All tests passed.")
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
|
|
2
|
+
from csvstats.anova import anova2way
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_anova2way_independent():
|
|
8
|
+
"""Test two-way independent samples ANOVA with known data."""
|
|
9
|
+
|
|
10
|
+
np.random.seed(42)
|
|
11
|
+
n = 20
|
|
12
|
+
|
|
13
|
+
# Create data with two factors: treatment (A/B) and sex (M/F)
|
|
14
|
+
# Factor 1 (treatment) has a significant effect, factor 2 (sex) does not
|
|
15
|
+
data = pd.DataFrame({
|
|
16
|
+
'treatment': np.repeat(['A', 'B'], n * 2),
|
|
17
|
+
'sex': np.tile(np.repeat(['M', 'F'], n), 2),
|
|
18
|
+
'score': np.concatenate([
|
|
19
|
+
np.random.normal(loc=5.0, scale=1.0, size=n), # A, M
|
|
20
|
+
np.random.normal(loc=5.0, scale=1.0, size=n), # A, F
|
|
21
|
+
np.random.normal(loc=8.0, scale=1.0, size=n), # B, M
|
|
22
|
+
np.random.normal(loc=8.0, scale=1.0, size=n), # B, F
|
|
23
|
+
])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
result = anova2way(data, group_column1='treatment', group_column2='sex',
|
|
27
|
+
data_column='score', filename=None)
|
|
28
|
+
|
|
29
|
+
# Verify result structure
|
|
30
|
+
assert result is not None
|
|
31
|
+
assert "test" in result
|
|
32
|
+
assert result["test"] == "Two-way Independent Samples ANOVA"
|
|
33
|
+
assert "date" in result
|
|
34
|
+
assert "main_effects" in result
|
|
35
|
+
assert "interaction" in result
|
|
36
|
+
assert "treatment" in result["main_effects"]
|
|
37
|
+
assert "sex" in result["main_effects"]
|
|
38
|
+
|
|
39
|
+
# Treatment should be significant (large mean difference)
|
|
40
|
+
assert "F" in result["main_effects"]["treatment"]
|
|
41
|
+
assert "p" in result["main_effects"]["treatment"]
|
|
42
|
+
assert result["main_effects"]["treatment"]["p"] < 0.05
|
|
43
|
+
|
|
44
|
+
# Sex should not be significant (no mean difference)
|
|
45
|
+
assert "F" in result["main_effects"]["sex"]
|
|
46
|
+
assert "p" in result["main_effects"]["sex"]
|
|
47
|
+
assert result["main_effects"]["sex"]["p"] >= 0.05
|
|
48
|
+
|
|
49
|
+
# Interaction
|
|
50
|
+
assert "F" in result["interaction"]
|
|
51
|
+
assert "p" in result["interaction"]
|
|
52
|
+
|
|
53
|
+
# Summary statistics
|
|
54
|
+
assert "summary_statistics_treatment" in result
|
|
55
|
+
assert "summary_statistics_sex" in result
|
|
56
|
+
assert "summary_statistics_interaction" in result
|
|
57
|
+
|
|
58
|
+
# Assumption tests for independent samples
|
|
59
|
+
assert "normality_test" in result
|
|
60
|
+
assert "homogeneity_of_variance_test" in result
|
|
61
|
+
assert result["homogeneity_of_variance_test"] != "Not applicable"
|
|
62
|
+
assert result["sphericity_test"] == "Not applicable"
|
|
63
|
+
|
|
64
|
+
# Post-hoc tests
|
|
65
|
+
assert "post_hoc" in result
|
|
66
|
+
assert "treatment" in result["post_hoc"]
|
|
67
|
+
assert "sex" in result["post_hoc"]
|
|
68
|
+
assert "interaction" in result["post_hoc"]
|
|
69
|
+
# Treatment is significant, so post-hoc should be a dict with results
|
|
70
|
+
assert result["post_hoc"]["treatment"] != "Not applicable"
|
|
71
|
+
# Sex is not significant, so post-hoc should be "Not applicable"
|
|
72
|
+
assert result["post_hoc"]["sex"] == "Not applicable"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_anova2way_independent_nonsignificant():
|
|
76
|
+
"""Test two-way independent samples ANOVA where no effects are significant."""
|
|
77
|
+
|
|
78
|
+
np.random.seed(42)
|
|
79
|
+
n = 20
|
|
80
|
+
|
|
81
|
+
# All groups have the same mean -> nothing significant
|
|
82
|
+
data = pd.DataFrame({
|
|
83
|
+
'factor1': np.repeat(['A', 'B'], n * 2),
|
|
84
|
+
'factor2': np.tile(np.repeat(['X', 'Y'], n), 2),
|
|
85
|
+
'score': np.random.normal(loc=10.0, scale=1.0, size=n * 4),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
result = anova2way(data, group_column1='factor1', group_column2='factor2',
|
|
89
|
+
data_column='score', filename=None)
|
|
90
|
+
|
|
91
|
+
assert result is not None
|
|
92
|
+
assert result["test"] == "Two-way Independent Samples ANOVA"
|
|
93
|
+
|
|
94
|
+
# All post-hocs should be "Not applicable"
|
|
95
|
+
assert result["post_hoc"]["factor1"] == "Not applicable"
|
|
96
|
+
assert result["post_hoc"]["factor2"] == "Not applicable"
|
|
97
|
+
assert result["post_hoc"]["interaction"] == "Not applicable"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_anova2way_skip_same_column_group1():
|
|
101
|
+
"""Test that anova2way returns None when data_column matches group_column1."""
|
|
102
|
+
|
|
103
|
+
data = pd.DataFrame({
|
|
104
|
+
'A': [1, 2, 3],
|
|
105
|
+
'B': [4, 5, 6],
|
|
106
|
+
'value': [7, 8, 9]
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
result = anova2way(data, group_column1='A', group_column2='B',
|
|
110
|
+
data_column='A', filename=None)
|
|
111
|
+
assert result is None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_anova2way_skip_same_column_group2():
|
|
115
|
+
"""Test that anova2way returns None when data_column matches group_column2."""
|
|
116
|
+
|
|
117
|
+
data = pd.DataFrame({
|
|
118
|
+
'A': [1, 2, 3],
|
|
119
|
+
'B': [4, 5, 6],
|
|
120
|
+
'value': [7, 8, 9]
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
result = anova2way(data, group_column1='A', group_column2='B',
|
|
124
|
+
data_column='B', filename=None)
|
|
125
|
+
assert result is None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_anova2way_skip_same_column_repeated_measures():
|
|
129
|
+
"""Test that anova2way returns None when data_column matches repeated_measures_column."""
|
|
130
|
+
|
|
131
|
+
data = pd.DataFrame({
|
|
132
|
+
'subject': [1, 2, 3],
|
|
133
|
+
'A': ['x', 'y', 'z'],
|
|
134
|
+
'B': ['a', 'b', 'c'],
|
|
135
|
+
'value': [7, 8, 9]
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
result = anova2way(data, group_column1='A', group_column2='B',
|
|
139
|
+
data_column='subject', repeated_measures_column='subject',
|
|
140
|
+
filename=None)
|
|
141
|
+
assert result is None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_anova2way_none_repeated_measures_column():
|
|
145
|
+
"""Test that passing None for repeated_measures_column is handled correctly."""
|
|
146
|
+
|
|
147
|
+
np.random.seed(42)
|
|
148
|
+
n = 20
|
|
149
|
+
|
|
150
|
+
data = pd.DataFrame({
|
|
151
|
+
'treatment': np.repeat(['A', 'B'], n * 2),
|
|
152
|
+
'sex': np.tile(np.repeat(['M', 'F'], n), 2),
|
|
153
|
+
'score': np.concatenate([
|
|
154
|
+
np.random.normal(loc=5.0, scale=1.0, size=n),
|
|
155
|
+
np.random.normal(loc=5.0, scale=1.0, size=n),
|
|
156
|
+
np.random.normal(loc=8.0, scale=1.0, size=n),
|
|
157
|
+
np.random.normal(loc=8.0, scale=1.0, size=n),
|
|
158
|
+
])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
result = anova2way(data, group_column1='treatment', group_column2='sex',
|
|
162
|
+
data_column='score', repeated_measures_column=None,
|
|
163
|
+
filename=None)
|
|
164
|
+
|
|
165
|
+
assert result is not None
|
|
166
|
+
assert result["test"] == "Two-way Independent Samples ANOVA"
|
|
167
|
+
assert result["repeated_measures_column"] == ""
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_anova2way_repeated_measures():
|
|
171
|
+
"""Test two-way repeated measures ANOVA."""
|
|
172
|
+
|
|
173
|
+
np.random.seed(7)
|
|
174
|
+
n_subjects = 15
|
|
175
|
+
|
|
176
|
+
# Each subject is measured under all 4 conditions (2 x 2 within-subject design)
|
|
177
|
+
subjects = np.tile(np.arange(n_subjects), 4)
|
|
178
|
+
factor1 = np.repeat(['low', 'high'], n_subjects * 2)
|
|
179
|
+
factor2 = np.tile(np.repeat(['pre', 'post'], n_subjects), 2)
|
|
180
|
+
|
|
181
|
+
# factor1 has a large effect, factor2 has a moderate effect
|
|
182
|
+
scores = np.concatenate([
|
|
183
|
+
np.random.normal(loc=10, scale=2, size=n_subjects), # low, pre
|
|
184
|
+
np.random.normal(loc=12, scale=2, size=n_subjects), # low, post
|
|
185
|
+
np.random.normal(loc=16, scale=2, size=n_subjects), # high, pre
|
|
186
|
+
np.random.normal(loc=18, scale=2, size=n_subjects), # high, post
|
|
187
|
+
])
|
|
188
|
+
|
|
189
|
+
data = pd.DataFrame({
|
|
190
|
+
'subject': subjects,
|
|
191
|
+
'dose': factor1,
|
|
192
|
+
'time': factor2,
|
|
193
|
+
'score': scores,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
result = anova2way(data, group_column1='dose', group_column2='time',
|
|
197
|
+
data_column='score', repeated_measures_column='subject',
|
|
198
|
+
filename=None)
|
|
199
|
+
|
|
200
|
+
assert result is not None
|
|
201
|
+
assert result["test"] == "Two-way Repeated Measures ANOVA"
|
|
202
|
+
assert result["repeated_measures_column"] == "subject"
|
|
203
|
+
assert "main_effects" in result
|
|
204
|
+
assert "dose" in result["main_effects"]
|
|
205
|
+
assert "time" in result["main_effects"]
|
|
206
|
+
assert "interaction" in result
|
|
207
|
+
|
|
208
|
+
# Both main effects should be significant given the large mean differences
|
|
209
|
+
assert result["main_effects"]["dose"]["p"] < 0.05
|
|
210
|
+
assert result["main_effects"]["time"]["p"] < 0.05
|
|
211
|
+
|
|
212
|
+
# Assumption tests for repeated measures
|
|
213
|
+
assert "normality_test" in result
|
|
214
|
+
assert result["sphericity_test"] != "Not applicable"
|
|
215
|
+
assert result["homogeneity_of_variance_test"] == "Not applicable"
|
|
216
|
+
|
|
217
|
+
# Post-hoc for significant effects
|
|
218
|
+
assert "post_hoc" in result
|
|
219
|
+
assert result["post_hoc"]["dose"] != "Not applicable"
|
|
220
|
+
assert result["post_hoc"]["time"] != "Not applicable"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_anova2way_repeated_measures_nonsignificant():
|
|
224
|
+
"""Test repeated measures two-way ANOVA with non-significant effects."""
|
|
225
|
+
|
|
226
|
+
np.random.seed(88)
|
|
227
|
+
n_subjects = 15
|
|
228
|
+
|
|
229
|
+
subjects = np.tile(np.arange(n_subjects), 4)
|
|
230
|
+
factor1 = np.repeat(['low', 'high'], n_subjects * 2)
|
|
231
|
+
factor2 = np.tile(np.repeat(['pre', 'post'], n_subjects), 2)
|
|
232
|
+
|
|
233
|
+
# Same mean across all conditions -> non-significant
|
|
234
|
+
scores = np.random.normal(loc=10, scale=2, size=n_subjects * 4)
|
|
235
|
+
|
|
236
|
+
data = pd.DataFrame({
|
|
237
|
+
'subject': subjects,
|
|
238
|
+
'dose': factor1,
|
|
239
|
+
'time': factor2,
|
|
240
|
+
'score': scores,
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
result = anova2way(data, group_column1='dose', group_column2='time',
|
|
244
|
+
data_column='score', repeated_measures_column='subject',
|
|
245
|
+
filename=None)
|
|
246
|
+
|
|
247
|
+
assert result is not None
|
|
248
|
+
assert result["test"] == "Two-way Repeated Measures ANOVA"
|
|
249
|
+
|
|
250
|
+
# All post-hocs should be "Not applicable" when nothing is significant
|
|
251
|
+
assert result["post_hoc"]["dose"] == "Not applicable"
|
|
252
|
+
assert result["post_hoc"]["time"] == "Not applicable"
|
|
253
|
+
assert result["post_hoc"]["interaction"] == "Not applicable"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_anova2way_all_columns():
|
|
257
|
+
"""Test the all-columns path (data_column == '_')."""
|
|
258
|
+
|
|
259
|
+
np.random.seed(3)
|
|
260
|
+
n = 20
|
|
261
|
+
|
|
262
|
+
data = pd.DataFrame({
|
|
263
|
+
'treatment': np.repeat(['A', 'B'], n * 2),
|
|
264
|
+
'sex': np.tile(np.repeat(['M', 'F'], n), 2),
|
|
265
|
+
'measure1': np.concatenate([
|
|
266
|
+
np.random.normal(loc=5.0, scale=1.0, size=n),
|
|
267
|
+
np.random.normal(loc=5.0, scale=1.0, size=n),
|
|
268
|
+
np.random.normal(loc=8.0, scale=1.0, size=n),
|
|
269
|
+
np.random.normal(loc=8.0, scale=1.0, size=n),
|
|
270
|
+
]),
|
|
271
|
+
'measure2': np.random.normal(loc=10.0, scale=1.0, size=n * 4),
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
results = anova2way(data, group_column1='treatment', group_column2='sex',
|
|
275
|
+
data_column='_', filename=None)
|
|
276
|
+
|
|
277
|
+
assert results is not None
|
|
278
|
+
assert isinstance(results, dict)
|
|
279
|
+
assert 'measure1' in results
|
|
280
|
+
assert 'measure2' in results
|
|
281
|
+
assert results['measure1']['test'] == "Two-way Independent Samples ANOVA"
|
|
282
|
+
assert results['measure2']['test'] == "Two-way Independent Samples ANOVA"
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def test_anova2way_all_columns_with_filename_placeholder():
|
|
286
|
+
"""Test all-columns path with {data_column} placeholder in filename."""
|
|
287
|
+
|
|
288
|
+
np.random.seed(3)
|
|
289
|
+
n = 20
|
|
290
|
+
|
|
291
|
+
data = pd.DataFrame({
|
|
292
|
+
'treatment': np.repeat(['A', 'B'], n * 2),
|
|
293
|
+
'sex': np.tile(np.repeat(['M', 'F'], n), 2),
|
|
294
|
+
'measure1': np.random.normal(loc=5.0, scale=1.0, size=n * 4),
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
# Use a filename with {data_column} placeholder - the function should format it
|
|
298
|
+
# without error. Use None-equivalent by checking no exception is raised.
|
|
299
|
+
results = anova2way(data, group_column1='treatment', group_column2='sex',
|
|
300
|
+
data_column='_',
|
|
301
|
+
filename='/tmp/test_{data_column}_results.pdf')
|
|
302
|
+
|
|
303
|
+
assert results is not None
|
|
304
|
+
assert 'measure1' in results
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def test_anova2way_all_columns_without_filename_placeholder():
|
|
308
|
+
"""Test all-columns path with a filename that has no {data_column} placeholder
|
|
309
|
+
(exercises the KeyError/IndexError fallback)."""
|
|
310
|
+
|
|
311
|
+
np.random.seed(3)
|
|
312
|
+
n = 20
|
|
313
|
+
|
|
314
|
+
data = pd.DataFrame({
|
|
315
|
+
'treatment': np.repeat(['A', 'B'], n * 2),
|
|
316
|
+
'sex': np.tile(np.repeat(['M', 'F'], n), 2),
|
|
317
|
+
'measure1': np.random.normal(loc=5.0, scale=1.0, size=n * 4),
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
# Plain filename without placeholder -> triggers the except branch
|
|
321
|
+
results = anova2way(data, group_column1='treatment', group_column2='sex',
|
|
322
|
+
data_column='_',
|
|
323
|
+
filename='/tmp/test_results.pdf')
|
|
324
|
+
|
|
325
|
+
assert results is not None
|
|
326
|
+
assert 'measure1' in results
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def test_anova2way_interaction_posthoc():
|
|
330
|
+
"""Test that interaction post-hoc tests are performed when interaction is significant."""
|
|
331
|
+
|
|
332
|
+
np.random.seed(123)
|
|
333
|
+
n = 30
|
|
334
|
+
|
|
335
|
+
# Create a crossover interaction: A+X high, A+Y low, B+X low, B+Y high
|
|
336
|
+
data = pd.DataFrame({
|
|
337
|
+
'factor1': np.repeat(['A', 'B'], n * 2),
|
|
338
|
+
'factor2': np.tile(np.repeat(['X', 'Y'], n), 2),
|
|
339
|
+
'score': np.concatenate([
|
|
340
|
+
np.random.normal(loc=12.0, scale=1.0, size=n), # A, X (high)
|
|
341
|
+
np.random.normal(loc=5.0, scale=1.0, size=n), # A, Y (low)
|
|
342
|
+
np.random.normal(loc=5.0, scale=1.0, size=n), # B, X (low)
|
|
343
|
+
np.random.normal(loc=12.0, scale=1.0, size=n), # B, Y (high)
|
|
344
|
+
])
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
result = anova2way(data, group_column1='factor1', group_column2='factor2',
|
|
348
|
+
data_column='score', filename=None)
|
|
349
|
+
|
|
350
|
+
assert result is not None
|
|
351
|
+
# The interaction should be significant due to the crossover pattern
|
|
352
|
+
assert result["interaction"]["p"] < 0.05
|
|
353
|
+
assert result["post_hoc"]["interaction"] != "Not applicable"
|
|
354
|
+
assert "correction_method" in result["post_hoc"]["interaction"]
|
|
355
|
+
assert "posthoc_results" in result["post_hoc"]["interaction"]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
if __name__ == "__main__":
|
|
359
|
+
test_anova2way_independent()
|
|
360
|
+
test_anova2way_independent_nonsignificant()
|
|
361
|
+
test_anova2way_skip_same_column_group1()
|
|
362
|
+
test_anova2way_skip_same_column_group2()
|
|
363
|
+
test_anova2way_skip_same_column_repeated_measures()
|
|
364
|
+
test_anova2way_none_repeated_measures_column()
|
|
365
|
+
test_anova2way_repeated_measures()
|
|
366
|
+
test_anova2way_repeated_measures_nonsignificant()
|
|
367
|
+
test_anova2way_all_columns()
|
|
368
|
+
test_anova2way_all_columns_with_filename_placeholder()
|
|
369
|
+
test_anova2way_all_columns_without_filename_placeholder()
|
|
370
|
+
test_anova2way_interaction_posthoc()
|
|
371
|
+
print("All tests passed.")
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""csvstats.core: the C3 core record, checked against scipy and hand values."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import math
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import pytest
|
|
9
|
+
from scipy import stats
|
|
10
|
+
|
|
11
|
+
from csvstats.core import (
|
|
12
|
+
NotSupported,
|
|
13
|
+
anova,
|
|
14
|
+
correct_pvalues,
|
|
15
|
+
friedman,
|
|
16
|
+
kruskal,
|
|
17
|
+
rank_test,
|
|
18
|
+
ttest,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _two_groups():
|
|
23
|
+
return pd.DataFrame({
|
|
24
|
+
"g": ["A"] * 5 + ["B"] * 6,
|
|
25
|
+
"y": [5.1, 4.8, 6.0, 5.5, 5.9, 3.9, 4.2, 4.0, 4.6, 3.5, 4.4],
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _paired():
|
|
30
|
+
rows = []
|
|
31
|
+
for s, (pre, post) in enumerate([(5.0, 6.1), (4.2, 4.9), (6.3, 6.8), (5.5, 6.9), (4.9, 5.0), (5.8, 6.6)]):
|
|
32
|
+
rows += [(f"S{s}", "pre", pre), (f"S{s}", "post", post)]
|
|
33
|
+
return pd.DataFrame(rows, columns=["subject", "time", "y"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --- correction -----------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_correct_pvalues_reference_values():
|
|
40
|
+
assert correct_pvalues([0.01, 0.04, 0.03], "holm") == pytest.approx([0.03, 0.06, 0.06])
|
|
41
|
+
assert correct_pvalues([0.01, 0.04, 0.03], "fdr_bh") == pytest.approx([0.03, 0.04, 0.04])
|
|
42
|
+
assert correct_pvalues([0.01, 0.4], "bonferroni") == pytest.approx([0.02, 0.8])
|
|
43
|
+
assert correct_pvalues([0.2, None, 0.6], "bonferroni") == [0.4, None, 1.0]
|
|
44
|
+
with pytest.raises(ValueError):
|
|
45
|
+
correct_pvalues([0.1], "tukey")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --- t-tests --------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_welch_matches_scipy_and_states_the_direction():
|
|
52
|
+
data = _two_groups()
|
|
53
|
+
out = ttest(data, "y", between=["g"])
|
|
54
|
+
(effect,) = out["effects"]
|
|
55
|
+
a = data[data.g == "A"].y.to_numpy()
|
|
56
|
+
b = data[data.g == "B"].y.to_numpy()
|
|
57
|
+
ref = stats.ttest_ind(a, b, equal_var=False)
|
|
58
|
+
assert out["test"] == "ttest_ind_welch"
|
|
59
|
+
assert (effect["a"], effect["b"]) == ("A", "B")
|
|
60
|
+
assert effect["statistic"] == pytest.approx(ref.statistic)
|
|
61
|
+
assert effect["p"] == pytest.approx(ref.pvalue)
|
|
62
|
+
va, vb = a.var(ddof=1) / len(a), b.var(ddof=1) / len(b)
|
|
63
|
+
df = (va + vb) ** 2 / (va**2 / (len(a) - 1) + vb**2 / (len(b) - 1))
|
|
64
|
+
assert effect["df"] == [pytest.approx(df)]
|
|
65
|
+
est = effect["estimate"]
|
|
66
|
+
assert est["value"] == pytest.approx(a.mean() - b.mean())
|
|
67
|
+
half = stats.t.ppf(0.975, df) * math.sqrt(va + vb)
|
|
68
|
+
assert est["ci"] == pytest.approx([est["value"] - half, est["value"] + half])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_levels_reverse_the_sign():
|
|
72
|
+
out = ttest(_two_groups(), "y", between=["g"], levels={"g": ["B", "A"]})
|
|
73
|
+
(effect,) = out["effects"]
|
|
74
|
+
assert (effect["a"], effect["b"]) == ("B", "A")
|
|
75
|
+
assert effect["estimate"]["value"] < 0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_student_effect_sizes_and_their_ci():
|
|
79
|
+
data = _two_groups()
|
|
80
|
+
out = ttest(data, "y", between=["g"], equal_var=True)
|
|
81
|
+
(effect,) = out["effects"]
|
|
82
|
+
a = data[data.g == "A"].y.to_numpy()
|
|
83
|
+
b = data[data.g == "B"].y.to_numpy()
|
|
84
|
+
assert effect["statistic"] == pytest.approx(stats.ttest_ind(a, b).statistic)
|
|
85
|
+
sp = math.sqrt(((len(a) - 1) * a.var(ddof=1) + (len(b) - 1) * b.var(ddof=1)) / (len(a) + len(b) - 2))
|
|
86
|
+
d = (a.mean() - b.mean()) / sp
|
|
87
|
+
sizes = effect["effect_sizes"]
|
|
88
|
+
assert sizes["d_s"]["value"] == pytest.approx(d)
|
|
89
|
+
assert sizes["g_s"]["value"] < sizes["d_s"]["value"], "J < 1 shrinks g"
|
|
90
|
+
lo, hi = sizes["d_s"]["ci"]
|
|
91
|
+
assert lo < d < hi
|
|
92
|
+
# The noncentral-t bounds put the observed t at the 97.5% / 2.5% points.
|
|
93
|
+
scale = math.sqrt(len(a) * len(b) / (len(a) + len(b)))
|
|
94
|
+
df = len(a) + len(b) - 2
|
|
95
|
+
assert stats.nct.cdf(d * scale, df, lo * scale) == pytest.approx(0.975, abs=1e-6)
|
|
96
|
+
assert stats.nct.cdf(d * scale, df, hi * scale) == pytest.approx(0.025, abs=1e-6)
|
|
97
|
+
assert effect["effect_size"]["name"] == "g_s"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_paired_t_matches_scipy_and_d_z():
|
|
101
|
+
data = _paired()
|
|
102
|
+
out = ttest(data, "y", within=["time"], subject="subject", levels={"time": ["post", "pre"]})
|
|
103
|
+
(effect,) = out["effects"]
|
|
104
|
+
wide = data.pivot(index="subject", columns="time", values="y")
|
|
105
|
+
ref = stats.ttest_rel(wide["post"], wide["pre"])
|
|
106
|
+
assert out["test"] == "ttest_paired"
|
|
107
|
+
assert effect["statistic"] == pytest.approx(ref.statistic)
|
|
108
|
+
assert effect["p"] == pytest.approx(ref.pvalue)
|
|
109
|
+
assert effect["df"] == [5.0]
|
|
110
|
+
d = (wide["post"] - wide["pre"]).to_numpy()
|
|
111
|
+
assert effect["estimate"]["sd_of_differences"] == pytest.approx(d.std(ddof=1))
|
|
112
|
+
assert effect["effect_sizes"]["d_z"]["value"] == pytest.approx(ref.statistic / math.sqrt(6))
|
|
113
|
+
assert effect["effect_size"]["name"] == "d_z"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_incomplete_subjects_are_dropped_and_reported():
|
|
117
|
+
data = _paired()
|
|
118
|
+
data = data[~((data.subject == "S0") & (data.time == "post"))]
|
|
119
|
+
out = ttest(data, "y", within=["time"], subject="subject")
|
|
120
|
+
assert out["n"]["subjects"] == 5
|
|
121
|
+
assert {"subject": "S0", "reason": "missing a within-subject level"} in out["n"]["excluded_detail"]
|
|
122
|
+
assert any(w["code"] == "subjects_dropped_incomplete" for w in out["warnings"])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_missing_values_are_reported_and_the_input_is_not_mutated():
|
|
126
|
+
data = _two_groups()
|
|
127
|
+
data.loc[0, "y"] = np.nan
|
|
128
|
+
before = data.copy()
|
|
129
|
+
out = ttest(data, "y", between=["g"])
|
|
130
|
+
pd.testing.assert_frame_equal(data, before)
|
|
131
|
+
assert out["n"]["total"] == 10
|
|
132
|
+
assert any(w["code"] == "rows_dropped_missing" for w in out["warnings"])
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_results_are_json_and_deterministic():
|
|
136
|
+
one = ttest(_two_groups(), "y", between=["g"])
|
|
137
|
+
two = ttest(_two_groups(), "y", between=["g"])
|
|
138
|
+
assert json.dumps(one, sort_keys=True) == json.dumps(two, sort_keys=True)
|
|
139
|
+
assert "date" not in one
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_bad_input_raises_naming_the_column():
|
|
143
|
+
with pytest.raises(ValueError, match="nope"):
|
|
144
|
+
ttest(_two_groups(), "nope", between=["g"])
|
|
145
|
+
with pytest.raises(NotSupported):
|
|
146
|
+
ttest(_two_groups(), "y", between=["g"], within=["g"])
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# --- rank tests -----------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_mann_whitney_matches_scipy_with_hodges_lehmann():
|
|
153
|
+
data = _two_groups()
|
|
154
|
+
out = rank_test(data, "y", between=["g"])
|
|
155
|
+
(effect,) = out["effects"]
|
|
156
|
+
a = data[data.g == "A"].y.to_numpy()
|
|
157
|
+
b = data[data.g == "B"].y.to_numpy()
|
|
158
|
+
ref = stats.mannwhitneyu(a, b)
|
|
159
|
+
assert effect["statistic"] == pytest.approx(ref.statistic)
|
|
160
|
+
assert effect["p"] == pytest.approx(ref.pvalue)
|
|
161
|
+
assert effect["estimate"]["value"] == pytest.approx(np.median((a[:, None] - b[None, :]).ravel()))
|
|
162
|
+
assert effect["effect_size"]["value"] == pytest.approx(2 * ref.statistic / (len(a) * len(b)) - 1)
|
|
163
|
+
assert effect["effect_size"]["value"] == pytest.approx(1.0), "every A exceeds every B"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_wilcoxon_matches_scipy():
|
|
167
|
+
data = _paired()
|
|
168
|
+
out = rank_test(data, "y", within=["time"], subject="subject", levels={"time": ["post", "pre"]})
|
|
169
|
+
(effect,) = out["effects"]
|
|
170
|
+
wide = data.pivot(index="subject", columns="time", values="y")
|
|
171
|
+
ref = stats.wilcoxon(wide["post"] - wide["pre"])
|
|
172
|
+
assert effect["statistic"] == pytest.approx(ref.statistic)
|
|
173
|
+
assert effect["p"] == pytest.approx(ref.pvalue)
|
|
174
|
+
assert effect["effect_size"]["value"] == pytest.approx(1.0), "every difference is positive"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- ANOVA family ---------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _three_groups():
|
|
181
|
+
rng = np.random.default_rng(3)
|
|
182
|
+
return pd.DataFrame({
|
|
183
|
+
"g": np.repeat(["A", "B", "C"], 8),
|
|
184
|
+
"y": np.concatenate([rng.normal(m, 1, 8) for m in (5, 6, 7.5)]),
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_one_way_anova_matches_scipy():
|
|
189
|
+
data = _three_groups()
|
|
190
|
+
out = anova(data, "y", between=["g"])
|
|
191
|
+
(effect,) = out["effects"]
|
|
192
|
+
ref = stats.f_oneway(*[data[data.g == g].y for g in "ABC"])
|
|
193
|
+
assert effect["statistic"] == pytest.approx(ref.statistic)
|
|
194
|
+
assert effect["p"] == pytest.approx(ref.pvalue)
|
|
195
|
+
assert effect["df"] == [2.0, 21.0]
|
|
196
|
+
f = ref.statistic
|
|
197
|
+
assert effect["effect_sizes"]["eta2_p"]["value"] == pytest.approx(2 * f / (2 * f + 21))
|
|
198
|
+
assert effect["effect_sizes"]["omega2_p"]["value"] == pytest.approx(2 * (f - 1) / (2 * (f - 1) + 24))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _rm(levels=("t1", "t2", "t3"), groups=None):
|
|
202
|
+
rng = np.random.default_rng(5)
|
|
203
|
+
rows = []
|
|
204
|
+
for s in range(8):
|
|
205
|
+
base = rng.normal(10, 2)
|
|
206
|
+
for i, t in enumerate(levels):
|
|
207
|
+
rows.append({"subject": f"S{s}", "time": t, "y": base + i + rng.normal(0, 0.5),
|
|
208
|
+
**({"group": "ctl" if s < 4 else "trt"} if groups else {})})
|
|
209
|
+
return pd.DataFrame(rows)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_rm_anova_applies_greenhouse_geisser_with_three_levels():
|
|
213
|
+
out = anova(_rm(), "y", within=["time"], subject="subject")
|
|
214
|
+
(effect,) = out["effects"]
|
|
215
|
+
assert out["test"] == "anova_rm"
|
|
216
|
+
assert effect["term"] == "time"
|
|
217
|
+
assert effect["sphericity_correction"]["method"] == "greenhouse_geisser"
|
|
218
|
+
assert effect["effect_size"]["name"] == "eta2_g"
|
|
219
|
+
assert 0 <= effect["effect_sizes"]["eta2_g"]["value"] <= effect["effect_sizes"]["eta2_p"]["value"]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_mixed_anova_terms_and_generalized_eta():
|
|
223
|
+
out = anova(_rm(groups=True), "y", between=["group"], within=["time"], subject="subject")
|
|
224
|
+
terms = [e["term"] for e in out["effects"]]
|
|
225
|
+
assert terms == ["group", "time", "group:time"]
|
|
226
|
+
for effect in out["effects"]:
|
|
227
|
+
g = effect["effect_sizes"]["eta2_g"]["value"]
|
|
228
|
+
assert 0 <= g <= effect["effect_sizes"]["eta2_p"]["value"] + 1e-12
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_unsupported_anova_design_says_so():
|
|
232
|
+
data = _rm(groups=True)
|
|
233
|
+
data["side"] = "L"
|
|
234
|
+
with pytest.raises(NotSupported, match="not yet in csv-stats"):
|
|
235
|
+
anova(data, "y", between=["group", "side"], within=["time"], subject="subject")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_kruskal_and_friedman():
|
|
239
|
+
data = _three_groups()
|
|
240
|
+
k = kruskal(data, "y", between=["g"])
|
|
241
|
+
ref = stats.kruskal(*[data[data.g == g].y for g in "ABC"])
|
|
242
|
+
assert k["effects"][0]["statistic"] == pytest.approx(ref.statistic)
|
|
243
|
+
f = friedman(_rm(), "y", within=["time"], subject="subject")
|
|
244
|
+
wide = _rm().pivot(index="subject", columns="time", values="y")
|
|
245
|
+
ref = stats.friedmanchisquare(wide.t1, wide.t2, wide.t3)
|
|
246
|
+
assert f["effects"][0]["statistic"] == pytest.approx(ref.statistic)
|
|
247
|
+
assert f["effects"][0]["effect_size"]["value"] == pytest.approx(ref.statistic / (8 * 2))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_pingouin_p_columns_are_read_under_both_spellings():
|
|
251
|
+
"""Regression: pingouin 0.6 renamed p-unc -> p_unc (KeyError)."""
|
|
252
|
+
from csvstats.core import _p_unc
|
|
253
|
+
|
|
254
|
+
assert _p_unc(pd.Series({"p_unc": 0.2})) == 0.2
|
|
255
|
+
assert _p_unc(pd.Series({"p-unc": 0.3})) == 0.3
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _curves(paired: bool):
|
|
259
|
+
rng = np.random.default_rng(11)
|
|
260
|
+
rows = []
|
|
261
|
+
for s in range(8):
|
|
262
|
+
base = np.sin(np.linspace(0, np.pi, 101)) + rng.normal(0, 0.05, 101)
|
|
263
|
+
for level, shift in (("pre", 0.0), ("post", 0.6)):
|
|
264
|
+
bump = shift * np.exp(-((np.arange(101) - 50) ** 2) / 50.0)
|
|
265
|
+
rows.append({"subject": f"S{s}" if paired else f"{level}{s}", "time": level,
|
|
266
|
+
"y": list(base + bump + rng.normal(0, 0.05, 101))})
|
|
267
|
+
return pd.DataFrame(rows)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def test_spm_refuses_unequal_lengths_before_needing_spm1d():
|
|
271
|
+
from csvstats.core import _curve_matrix
|
|
272
|
+
|
|
273
|
+
with pytest.raises(ValueError, match="unequal length"):
|
|
274
|
+
_curve_matrix([[1, 2, 3], [1, 2]], "x")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_spm_paired_finds_the_middle_cluster():
|
|
278
|
+
pytest.importorskip("spm1d", reason="SPM tests need spm1d (pip install spm1d)")
|
|
279
|
+
from csvstats.core import spm_ttest
|
|
280
|
+
|
|
281
|
+
out = spm_ttest(_curves(True), "y", within=["time"], subject="subject", levels={"time": ["post", "pre"]})
|
|
282
|
+
(effect,) = out["effects"]
|
|
283
|
+
assert out["test"] == "spm_ttest_paired"
|
|
284
|
+
assert effect["clusters"], "the bump at node 50 is supra-threshold"
|
|
285
|
+
cluster = effect["clusters"][0]
|
|
286
|
+
assert cluster["start"] < 50 < cluster["end"]
|
|
287
|
+
assert effect["p"] is not None and effect["p"] < 0.05
|
|
288
|
+
assert len(effect["curve"]) == 101
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""csvstats.descriptive: the one definition of each descriptive formula."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from csvstats.descriptive import (
|
|
11
|
+
ci_half_width,
|
|
12
|
+
ci_interval,
|
|
13
|
+
sem,
|
|
14
|
+
summarize,
|
|
15
|
+
t_critical,
|
|
16
|
+
)
|
|
17
|
+
from csvstats.utils.summary_stats import calculate_summary_statistics
|
|
18
|
+
|
|
19
|
+
#: t(0.975, n - 1) from published tables.
|
|
20
|
+
T_975 = {2: 12.7062, 5: 2.7764, 30: 2.0452}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.parametrize("n", sorted(T_975))
|
|
24
|
+
def test_t_critical_matches_published_tables(n):
|
|
25
|
+
assert t_critical(n) == pytest.approx(T_975[n], abs=1e-4)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_t_critical_is_nan_below_two_values():
|
|
29
|
+
assert np.isnan(t_critical(1))
|
|
30
|
+
assert np.isnan(t_critical(0))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_array_and_series_shapes_are_kept():
|
|
34
|
+
counts = pd.Series([2, 5, 1], index=["a", "b", "c"])
|
|
35
|
+
out = t_critical(counts)
|
|
36
|
+
assert list(out.index) == ["a", "b", "c"]
|
|
37
|
+
assert out["a"] == pytest.approx(T_975[2], abs=1e-4)
|
|
38
|
+
assert np.isnan(out["c"])
|
|
39
|
+
widths = ci_half_width(np.array([1.0, 2.0]), np.array([5, 30]))
|
|
40
|
+
assert widths == pytest.approx([T_975[5] / np.sqrt(5), 2 * T_975[30] / np.sqrt(30)], rel=1e-4)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize("n", sorted(T_975))
|
|
44
|
+
def test_ci_is_t_based_not_z(n):
|
|
45
|
+
values = np.arange(1.0, n + 1.0) ** 1.5
|
|
46
|
+
sd = values.std(ddof=1)
|
|
47
|
+
low, high = ci_interval(values)
|
|
48
|
+
half = T_975[n] * sd / np.sqrt(n)
|
|
49
|
+
assert (high - low) / 2 == pytest.approx(half, rel=1e-4)
|
|
50
|
+
assert (high - low) / 2 != pytest.approx(1.96 * sd / np.sqrt(n), rel=1e-3)
|
|
51
|
+
assert ci_half_width(sd, n) == pytest.approx(half, rel=1e-4)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_ci_on_the_median_keeps_the_mean_half_width():
|
|
55
|
+
values = [1.0, 2.0, 3.0, 10.0]
|
|
56
|
+
low, high = ci_interval(values, centre="median")
|
|
57
|
+
assert (low + high) / 2 == pytest.approx(2.5)
|
|
58
|
+
assert (high - low) / 2 == pytest.approx(float(ci_half_width(np.std(values, ddof=1), 4)))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_missing_values_are_skipped_and_counted_separately():
|
|
62
|
+
s = summarize([1.0, 2.0, 3.0, None, np.nan])
|
|
63
|
+
assert s["n"] == 3 and s["n_missing"] == 2
|
|
64
|
+
assert s["mean"] == pytest.approx(2.0)
|
|
65
|
+
assert s["sd"] == pytest.approx(1.0)
|
|
66
|
+
assert s["sem"] == pytest.approx(1.0 / np.sqrt(3))
|
|
67
|
+
assert s["ci_high"] - s["mean"] == pytest.approx(float(t_critical(3)) / np.sqrt(3))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_summarize_reference_values():
|
|
71
|
+
values = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0]
|
|
72
|
+
s = summarize(values)
|
|
73
|
+
assert s["mean"] == pytest.approx(5.0)
|
|
74
|
+
assert s["sd"] == pytest.approx(np.std(values, ddof=1))
|
|
75
|
+
assert s["median"] == pytest.approx(4.5)
|
|
76
|
+
assert (s["q1"], s["q3"]) == pytest.approx((4.0, 5.5))
|
|
77
|
+
assert s["iqr"] == pytest.approx(1.5)
|
|
78
|
+
assert s["range"] == pytest.approx(7.0)
|
|
79
|
+
assert s["mode"] == 4.0 and s["mode_count"] == 3
|
|
80
|
+
assert s["cv"] == pytest.approx(s["sd"] / 5.0)
|
|
81
|
+
# Bias-corrected G1 / G2 (scipy bias=False), as SPSS reports them.
|
|
82
|
+
assert s["skewness"] == pytest.approx(0.8184875, abs=1e-6)
|
|
83
|
+
assert s["kurtosis"] == pytest.approx(0.940625, abs=1e-6)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_too_few_values_give_nan_not_zero():
|
|
87
|
+
one = summarize([3.0])
|
|
88
|
+
assert one["n"] == 1 and one["mean"] == 3.0
|
|
89
|
+
for key in ("sd", "sem", "ci_low", "ci_high", "variance", "skewness", "kurtosis"):
|
|
90
|
+
assert np.isnan(one[key]), key
|
|
91
|
+
assert np.isnan(sem(1.0, 1))
|
|
92
|
+
empty = summarize([])
|
|
93
|
+
assert empty["n"] == 0 and np.isnan(empty["mean"])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_summary_statistics_count_excludes_missing_values():
|
|
97
|
+
"""Regression: count was len(group), which counted NaN, so n and the CI
|
|
98
|
+
disagreed with the mean and SD."""
|
|
99
|
+
data = pd.DataFrame({"g": ["a"] * 4 + ["b"] * 3, "v": [1.0, 2.0, 3.0, np.nan, 5.0, 6.0, 7.0]})
|
|
100
|
+
out = calculate_summary_statistics(data, "g", "v")
|
|
101
|
+
assert out["grouped"]["count"] == {"a": 3, "b": 3}
|
|
102
|
+
assert out["grouped"]["n_missing"] == {"a": 1, "b": 0}
|
|
103
|
+
assert out["grouped"]["ci_95_high"]["a"] == pytest.approx(summarize([1.0, 2.0, 3.0])["ci_high"])
|
|
104
|
+
assert out["overall"]["count"] == 6
|
|
105
|
+
assert out["overall"]["n_missing"] == 1
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_descriptive_imports_no_heavy_dependencies():
|
|
109
|
+
"""A plotting package depends on this module; it must stay light."""
|
|
110
|
+
code = (
|
|
111
|
+
"import sys, csvstats.descriptive\n"
|
|
112
|
+
"heavy = {'pingouin', 'statsmodels', 'matplotlib', 'reportlab', 'seaborn'}\n"
|
|
113
|
+
"print(sorted(heavy & {m.split('.')[0] for m in sys.modules}))\n"
|
|
114
|
+
)
|
|
115
|
+
out = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True)
|
|
116
|
+
assert out.stdout.strip() == "[]", out.stdout
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|