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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: csv-stats
3
- Version: 0.2.0
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.0"
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
- packages = ["src/csvstats"]
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