statstables 0.0a1__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.
- statstables/__init__.py +1 -0
- statstables/renderers.py +237 -0
- statstables/tables.py +939 -0
- statstables/tests/__init__.py +0 -0
- statstables/tests/conftest.py +36 -0
- statstables/tests/test_tables.py +41 -0
- statstables/utils.py +38 -0
- statstables-0.0a1.dist-info/LICENSE +674 -0
- statstables-0.0a1.dist-info/METADATA +707 -0
- statstables-0.0a1.dist-info/RECORD +12 -0
- statstables-0.0a1.dist-info/WHEEL +5 -0
- statstables-0.0a1.dist-info/top_level.txt +1 -0
statstables/tables.py
ADDED
|
@@ -0,0 +1,939 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
import warnings
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from scipy import stats
|
|
6
|
+
from typing import Union
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from .renderers import LatexRenderer, HTMLRenderer
|
|
10
|
+
from .utils import pstars, validate_line_location
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Table(ABC):
|
|
14
|
+
"""
|
|
15
|
+
Abstract class for defining common characteristics/methods of all tables
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
self.reset_params()
|
|
20
|
+
|
|
21
|
+
def reset_params(self) -> None:
|
|
22
|
+
"""
|
|
23
|
+
Resets all parameters to their default values
|
|
24
|
+
"""
|
|
25
|
+
self.caption_location = "bottom"
|
|
26
|
+
self.caption = None
|
|
27
|
+
self.label = None
|
|
28
|
+
self.sig_digits = 3
|
|
29
|
+
self.thousands_sep = ","
|
|
30
|
+
self._index_labels = dict()
|
|
31
|
+
self._column_labels = dict()
|
|
32
|
+
self._multicolumns = []
|
|
33
|
+
self._formatters = dict()
|
|
34
|
+
self.notes = []
|
|
35
|
+
self.custom_lines = defaultdict(list)
|
|
36
|
+
self.custom_tex_lines = defaultdict(list)
|
|
37
|
+
self.custom_html_lines = defaultdict(list)
|
|
38
|
+
self.include_index = False
|
|
39
|
+
self.index_name = ""
|
|
40
|
+
self.show_columns = True
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def caption_location(self) -> None:
|
|
44
|
+
"""
|
|
45
|
+
Location of the caption in the table. Can be either 'top' or 'bottom'.
|
|
46
|
+
"""
|
|
47
|
+
return self._caption_location
|
|
48
|
+
|
|
49
|
+
@caption_location.setter
|
|
50
|
+
def caption_location(self, location: str) -> None:
|
|
51
|
+
assert location in [
|
|
52
|
+
"top",
|
|
53
|
+
"bottom",
|
|
54
|
+
], "caption_location must be 'top' or 'bottom'"
|
|
55
|
+
self._caption_location = location
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def caption(self) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Caption for the table. This will be placed above or below the table,
|
|
61
|
+
depending on the caption_location parameter.
|
|
62
|
+
"""
|
|
63
|
+
return self._caption
|
|
64
|
+
|
|
65
|
+
@caption.setter
|
|
66
|
+
def caption(self, caption: str = None) -> None:
|
|
67
|
+
assert isinstance(caption, (str, type(None))), "Caption must be a string"
|
|
68
|
+
self._caption = caption
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def label(self) -> None:
|
|
72
|
+
"""
|
|
73
|
+
Label for the table. This will be used to reference the table in LaTeX.
|
|
74
|
+
"""
|
|
75
|
+
return self._label
|
|
76
|
+
|
|
77
|
+
@label.setter
|
|
78
|
+
def label(self, label: str = None) -> None:
|
|
79
|
+
assert isinstance(label, (str, type(None))), "Label must be a string"
|
|
80
|
+
self._label = label
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def sig_digits(self) -> int:
|
|
84
|
+
"""
|
|
85
|
+
Number of significant digits to include in the table
|
|
86
|
+
"""
|
|
87
|
+
return self._sig_digits
|
|
88
|
+
|
|
89
|
+
@sig_digits.setter
|
|
90
|
+
def sig_digits(self, digits: int) -> None:
|
|
91
|
+
assert isinstance(digits, int), "sig_digits must be an integer"
|
|
92
|
+
self._sig_digits = digits
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def thousands_sep(self) -> str:
|
|
96
|
+
"""
|
|
97
|
+
Character to use as the thousands separator in the table
|
|
98
|
+
"""
|
|
99
|
+
return self._thousands_sep
|
|
100
|
+
|
|
101
|
+
@thousands_sep.setter
|
|
102
|
+
def thousands_sep(self, sep: str) -> None:
|
|
103
|
+
assert isinstance(sep, str), "thousands_sep must be a string"
|
|
104
|
+
self._thousands_sep = sep
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def include_index(self) -> bool:
|
|
108
|
+
"""
|
|
109
|
+
Whether or not to include the index in the table
|
|
110
|
+
"""
|
|
111
|
+
return self._include_index
|
|
112
|
+
|
|
113
|
+
@include_index.setter
|
|
114
|
+
def include_index(self, include: bool) -> None:
|
|
115
|
+
assert isinstance(include, bool), "include_index must be True or False"
|
|
116
|
+
self._include_index = include
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def index_name(self) -> str:
|
|
120
|
+
"""
|
|
121
|
+
Name of the index column in the table
|
|
122
|
+
"""
|
|
123
|
+
return self._index_name
|
|
124
|
+
|
|
125
|
+
@index_name.setter
|
|
126
|
+
def index_name(self, name: str) -> None:
|
|
127
|
+
assert isinstance(name, str), "index_name must be a string"
|
|
128
|
+
self._index_name = name
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def show_columns(self) -> bool:
|
|
132
|
+
"""
|
|
133
|
+
Whether or not to show the column labels in the table
|
|
134
|
+
"""
|
|
135
|
+
return self._show_columns
|
|
136
|
+
|
|
137
|
+
@show_columns.setter
|
|
138
|
+
def show_columns(self, show: bool) -> None:
|
|
139
|
+
assert isinstance(show, bool), "show_columns must be True or False"
|
|
140
|
+
self._show_columns = show
|
|
141
|
+
|
|
142
|
+
def rename_columns(self, columndict: dict) -> None:
|
|
143
|
+
"""
|
|
144
|
+
Rename the columns in the table. The keys of the columndict should be the
|
|
145
|
+
current column labels and the values should be the new labels.
|
|
146
|
+
|
|
147
|
+
Parameters
|
|
148
|
+
----------
|
|
149
|
+
columndict : dict
|
|
150
|
+
_description_
|
|
151
|
+
"""
|
|
152
|
+
assert isinstance(columndict, dict), "columndict must be a dictionary"
|
|
153
|
+
self._column_labels.update(columndict)
|
|
154
|
+
|
|
155
|
+
def rename_index(self, indexdict: dict) -> None:
|
|
156
|
+
"""
|
|
157
|
+
Rename the index labels in the table. The keys of the indexdict should
|
|
158
|
+
be the current index labels and the values should be the new labels.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
indexdict : dict
|
|
163
|
+
Dictionary where the keys are the current index labels and the values
|
|
164
|
+
are the new labels.
|
|
165
|
+
"""
|
|
166
|
+
assert isinstance(indexdict, dict), "indexdict must be a dictionary"
|
|
167
|
+
self._index_labels.update(indexdict)
|
|
168
|
+
|
|
169
|
+
# TODO: Add method for creating index labels that span multiple rows
|
|
170
|
+
def add_multicolumns(
|
|
171
|
+
self,
|
|
172
|
+
columns: Union[str, list[str]],
|
|
173
|
+
spans: list[int],
|
|
174
|
+
formats: list[str] = None,
|
|
175
|
+
) -> None:
|
|
176
|
+
"""
|
|
177
|
+
All columns that span multiple columns in the table. These will be placed
|
|
178
|
+
above the individual column labels. The sum of the spans must equal the
|
|
179
|
+
number of columns in the table, not including the index.
|
|
180
|
+
|
|
181
|
+
Parameters
|
|
182
|
+
----------
|
|
183
|
+
columns : Union[str, list[str]]
|
|
184
|
+
If a single string is provided, it will span the entire table. If a list
|
|
185
|
+
is provided, each will span the number of columns in the corresponding
|
|
186
|
+
index of the spans list.
|
|
187
|
+
spans : list[int]
|
|
188
|
+
List of how many columns each multicolumn should span.
|
|
189
|
+
formats : list[str], optional
|
|
190
|
+
Not implemented yet. Will eventually allow for text formatting (bold,
|
|
191
|
+
underline, etc.), by default None
|
|
192
|
+
"""
|
|
193
|
+
# TODO: implement formats (underline, bold, etc.)
|
|
194
|
+
# TODO: Allow for placing the multicolumns below the table body
|
|
195
|
+
if not spans:
|
|
196
|
+
spans = [self.ncolumns]
|
|
197
|
+
assert len(columns) == len(spans), "columns and spans must be the same length"
|
|
198
|
+
assert (
|
|
199
|
+
sum(spans) == self.ncolumns
|
|
200
|
+
), "The sum of spans must equal the number of columns"
|
|
201
|
+
self._multicolumns.append((columns, spans))
|
|
202
|
+
|
|
203
|
+
def custom_formatters(self, formatters: dict) -> None:
|
|
204
|
+
"""
|
|
205
|
+
Method to set custom formatters either along the columns or index. Each
|
|
206
|
+
key in the formatters dict must be a function that returns a string.
|
|
207
|
+
|
|
208
|
+
You cannot set both column and index formatters at this time. Whichever
|
|
209
|
+
is set last will be the one used.
|
|
210
|
+
|
|
211
|
+
Parameters
|
|
212
|
+
----------
|
|
213
|
+
formatters : dict
|
|
214
|
+
Dictionary of fuctions to format the values. The keys should correspond
|
|
215
|
+
to either a column or index label in the table. If you want to format
|
|
216
|
+
along both axis, the key should be a tuple of the form: (index, column)
|
|
217
|
+
axis : str, optional
|
|
218
|
+
Which axis to format along, by default "columns"
|
|
219
|
+
|
|
220
|
+
Raises
|
|
221
|
+
------
|
|
222
|
+
ValueError
|
|
223
|
+
Error is raised if the values in the formatters dict are not functions
|
|
224
|
+
"""
|
|
225
|
+
assert all(
|
|
226
|
+
callable(f) for f in formatters.values()
|
|
227
|
+
), "Values in the formatters dict must be functions"
|
|
228
|
+
self._formatters.update(formatters)
|
|
229
|
+
|
|
230
|
+
def add_note(self, note: str, alignment: str = "l", escape: bool = True) -> None:
|
|
231
|
+
"""
|
|
232
|
+
Adds a single line note to the bottom on the table, under the bottom line.
|
|
233
|
+
|
|
234
|
+
Parameters
|
|
235
|
+
----------
|
|
236
|
+
note : str
|
|
237
|
+
The text of the note
|
|
238
|
+
alignment : str, optional
|
|
239
|
+
Which side of the table to align the note, by default "l"
|
|
240
|
+
escape : bool, optional
|
|
241
|
+
If true, a \ is added LaTeX characters that must be escaped, by default True
|
|
242
|
+
"""
|
|
243
|
+
assert isinstance(note, str), "Note must be a string"
|
|
244
|
+
assert alignment in ["l", "c", "r"], "alignment must be 'l', 'c', or 'r'"
|
|
245
|
+
self.notes.append((note, alignment, escape))
|
|
246
|
+
|
|
247
|
+
def remove_note(self, note: str = None, index: int = None) -> None:
|
|
248
|
+
"""
|
|
249
|
+
Removes a note that has been added to the table. To specify which note,
|
|
250
|
+
either pass the text of the note as the 'note' parameter or the index of
|
|
251
|
+
the note as the 'index' parameter.
|
|
252
|
+
|
|
253
|
+
Parameters
|
|
254
|
+
----------
|
|
255
|
+
note : str, optional
|
|
256
|
+
Text of note to remove, by default None
|
|
257
|
+
index : int, optional
|
|
258
|
+
Index of the note to be removed, by default None
|
|
259
|
+
|
|
260
|
+
Raises
|
|
261
|
+
------
|
|
262
|
+
ValueError
|
|
263
|
+
Raises and error if neither 'note' or 'index' are provided
|
|
264
|
+
"""
|
|
265
|
+
if note is None and index is None:
|
|
266
|
+
raise ValueError("Either 'note' or 'index' must be provided")
|
|
267
|
+
if note is not None:
|
|
268
|
+
self.notes.remove(note)
|
|
269
|
+
elif index is not None:
|
|
270
|
+
self.notes.pop(index)
|
|
271
|
+
|
|
272
|
+
def add_line(
|
|
273
|
+
self, line: list[str], location: str = "after-body", label: str = ""
|
|
274
|
+
) -> None:
|
|
275
|
+
"""
|
|
276
|
+
Add a line to the table that will be rendered at the specified location.
|
|
277
|
+
The line will be formatted to fit the table and the number of elements in
|
|
278
|
+
the list should equal the number of columns in the table. The index label
|
|
279
|
+
for the line is an empty string by default, but can be specified with the
|
|
280
|
+
label parameter.
|
|
281
|
+
|
|
282
|
+
Parameters
|
|
283
|
+
----------
|
|
284
|
+
line : list[str]
|
|
285
|
+
A list with each element that will comprise the line. the number of
|
|
286
|
+
elements of this list should equal the number of columns in the table
|
|
287
|
+
location : str, optional
|
|
288
|
+
Where on the table to place the line, by default "bottom"
|
|
289
|
+
label : str, optional:
|
|
290
|
+
The index label for the line, by default ""
|
|
291
|
+
"""
|
|
292
|
+
validate_line_location(location)
|
|
293
|
+
assert len(line) == self.ncolumns, "Line must have the same number of columns"
|
|
294
|
+
self.custom_lines[location].append({"line": line, "label": label})
|
|
295
|
+
|
|
296
|
+
def remove_line(self, location: str, line: list = None, index: int = None) -> None:
|
|
297
|
+
"""
|
|
298
|
+
Remove a custom line. To specify which line to remove, either pass the list
|
|
299
|
+
containing the line as the 'line' parameter or the index of the line as the
|
|
300
|
+
'index' parameter.
|
|
301
|
+
|
|
302
|
+
Parameters
|
|
303
|
+
----------
|
|
304
|
+
location : str
|
|
305
|
+
Where in the table the line is located
|
|
306
|
+
line : list, optional
|
|
307
|
+
List containing the line elements, by default None
|
|
308
|
+
index : int, optional
|
|
309
|
+
Index of the line in the custom line list for the specified location, by default None
|
|
310
|
+
|
|
311
|
+
Raises
|
|
312
|
+
------
|
|
313
|
+
ValueError
|
|
314
|
+
Raises an error if neither 'line' or 'index' are provided, or if the
|
|
315
|
+
line cannot be found in the custom lines list.
|
|
316
|
+
"""
|
|
317
|
+
validate_line_location(location)
|
|
318
|
+
if line is None and index is None:
|
|
319
|
+
raise ValueError("Either 'line' or 'index' must be provided")
|
|
320
|
+
|
|
321
|
+
if line is not None:
|
|
322
|
+
self.custom_lines[location].remove(line)
|
|
323
|
+
elif index is not None:
|
|
324
|
+
self.custom_lines[location].pop(index)
|
|
325
|
+
|
|
326
|
+
def add_latex_line(self, line: str, location: str = "bottom") -> None:
|
|
327
|
+
"""
|
|
328
|
+
Add line that will only be rendered in the LaTeX output. This method
|
|
329
|
+
assumes the line is formatted as needed, including escape characters and
|
|
330
|
+
line breaks. The provided line will be rendered as is. Note that this is
|
|
331
|
+
different from the generic add_line method, which will format the line
|
|
332
|
+
to fit in either LaTeX or HTML output.
|
|
333
|
+
|
|
334
|
+
Parameters
|
|
335
|
+
----------
|
|
336
|
+
line : str
|
|
337
|
+
The line to add to the table
|
|
338
|
+
location : str, optional
|
|
339
|
+
Where in the table to place the line, by default "bottom"
|
|
340
|
+
"""
|
|
341
|
+
validate_line_location(location)
|
|
342
|
+
self.custom_tex_lines[location].append(line)
|
|
343
|
+
|
|
344
|
+
def remove_latex_line(
|
|
345
|
+
self, location: str, line: str = None, index: int = None
|
|
346
|
+
) -> None:
|
|
347
|
+
"""
|
|
348
|
+
Remove a custom LaTex line. To specify which line to remove, either pass the list
|
|
349
|
+
containing the line as the 'line' parameter or the index of the line as the
|
|
350
|
+
'index' parameter.
|
|
351
|
+
|
|
352
|
+
Parameters
|
|
353
|
+
----------
|
|
354
|
+
location : str
|
|
355
|
+
Where in the table the line is located.
|
|
356
|
+
line : list, optional
|
|
357
|
+
List containing the line elements.
|
|
358
|
+
index : int, optional
|
|
359
|
+
Index of the line in the custom line list for the specified location.
|
|
360
|
+
|
|
361
|
+
Raises
|
|
362
|
+
------
|
|
363
|
+
ValueError
|
|
364
|
+
Raises an error if neither 'line' or 'index' are provided, or if the
|
|
365
|
+
line cannot be found in the custom lines list.
|
|
366
|
+
"""
|
|
367
|
+
validate_line_location(location)
|
|
368
|
+
if line is None and index is None:
|
|
369
|
+
raise ValueError("Either 'line' or 'index' must be provided")
|
|
370
|
+
|
|
371
|
+
if line is not None:
|
|
372
|
+
self.custom_tex_lines[location].remove(line)
|
|
373
|
+
elif index is not None:
|
|
374
|
+
self.custom_tex_lines[location].pop(index)
|
|
375
|
+
|
|
376
|
+
def add_html_line(self, line: str, location: str = "bottom") -> None:
|
|
377
|
+
"""
|
|
378
|
+
Add line that will only be rendered in the HTML output. This method
|
|
379
|
+
assumes the line is formatted as needed, including line breaks. The
|
|
380
|
+
provided line will be rendered as is. Note that this is different from
|
|
381
|
+
the generic add_line method, which will format the line to fit in either
|
|
382
|
+
LaTeX or HTML output.
|
|
383
|
+
|
|
384
|
+
Parameters
|
|
385
|
+
----------
|
|
386
|
+
line : str
|
|
387
|
+
The line to add to the table
|
|
388
|
+
location : str, optional
|
|
389
|
+
Where in the table to place the line. By default "bottom", other options
|
|
390
|
+
are: 'top', 'after-multicolumns', 'after-columns', 'after-body', 'after-footer'.
|
|
391
|
+
Note: not all of these are implemented yet.
|
|
392
|
+
"""
|
|
393
|
+
validate_line_location(location)
|
|
394
|
+
self.custom_html_lines[location].append(line)
|
|
395
|
+
|
|
396
|
+
def remove_html_line(self, location: str, line: str = None, index: int = None):
|
|
397
|
+
validate_line_location(location)
|
|
398
|
+
if line is None and index is None:
|
|
399
|
+
raise ValueError("Either 'line' or 'index' must be provided")
|
|
400
|
+
|
|
401
|
+
if line is not None:
|
|
402
|
+
self.custom_html_lines[location].remove(line)
|
|
403
|
+
elif index is not None:
|
|
404
|
+
self.custom_html_lines[location].pop(index)
|
|
405
|
+
|
|
406
|
+
def render_latex(
|
|
407
|
+
self, outfile: Union[str, Path] = None, only_tabular=False
|
|
408
|
+
) -> Union[str, None]:
|
|
409
|
+
"""
|
|
410
|
+
Render the table in LaTeX. Note that you will need to include the booktabs
|
|
411
|
+
package in your LaTeX document. If no outfile is provided, the LaTeX
|
|
412
|
+
string will be returned, otherwise the text will be written to the specified
|
|
413
|
+
file.
|
|
414
|
+
|
|
415
|
+
Parameters
|
|
416
|
+
----------
|
|
417
|
+
outfile : str, Path, optional
|
|
418
|
+
File to write the text to, by default None.
|
|
419
|
+
only_tabular : bool, optional
|
|
420
|
+
If True, the text will only be wrapped in a tabular enviroment. If
|
|
421
|
+
false, the text will also be wrapped in a table enviroment. It is
|
|
422
|
+
False by default.
|
|
423
|
+
|
|
424
|
+
Returns
|
|
425
|
+
-------
|
|
426
|
+
Union[str, None]
|
|
427
|
+
If an outfile is not specified, the LaTeX string will be returned.
|
|
428
|
+
Otherwise None will be returned.
|
|
429
|
+
"""
|
|
430
|
+
tex_str = LatexRenderer(self).render(only_tabular=only_tabular)
|
|
431
|
+
if not outfile:
|
|
432
|
+
return tex_str
|
|
433
|
+
Path(outfile).write_text(tex_str)
|
|
434
|
+
|
|
435
|
+
def render_html(self, outfile: Union[str, Path] = None) -> Union[str, None]:
|
|
436
|
+
"""
|
|
437
|
+
Render the table in HTML. Note that you will need to include the booktabs
|
|
438
|
+
package in your LaTeX document. If no outfile is provided, the LaTeX
|
|
439
|
+
string will be returned, otherwise the text will be written to the specified
|
|
440
|
+
file.
|
|
441
|
+
|
|
442
|
+
This is also used in the _repr_html_ method to render the tables in
|
|
443
|
+
Jupyter notebooks.
|
|
444
|
+
|
|
445
|
+
Parameters
|
|
446
|
+
----------
|
|
447
|
+
outfile : str, Path, optional
|
|
448
|
+
File to write the text to, by default None.
|
|
449
|
+
|
|
450
|
+
Returns
|
|
451
|
+
-------
|
|
452
|
+
Union[str, None]
|
|
453
|
+
If an outfile is not specified, the HTML string will be returned.
|
|
454
|
+
Otherwise None will be returned.
|
|
455
|
+
"""
|
|
456
|
+
html_str = HTMLRenderer(self).render()
|
|
457
|
+
if not outfile:
|
|
458
|
+
return html_str
|
|
459
|
+
Path(outfile).write_text(html_str)
|
|
460
|
+
|
|
461
|
+
def _repr_html_(self) -> str:
|
|
462
|
+
return self.render_html()
|
|
463
|
+
|
|
464
|
+
def _default_formatter(self, value: Union[int, float, str]) -> str:
|
|
465
|
+
if isinstance(value, (int, float)):
|
|
466
|
+
return f"{value:{self.thousands_sep}.{self.sig_digits}f}"
|
|
467
|
+
elif isinstance(value, str):
|
|
468
|
+
return value
|
|
469
|
+
return value
|
|
470
|
+
|
|
471
|
+
def _format_value(self, _index: str, col: str, value: Union[int, float, str]):
|
|
472
|
+
if (_index, col) in self._formatters.keys():
|
|
473
|
+
formatter = self._formatters[(_index, col)]
|
|
474
|
+
elif _index in self._formatters.keys():
|
|
475
|
+
formatter = self._formatters.get(_index, self._default_formatter)
|
|
476
|
+
elif col in self._formatters.keys():
|
|
477
|
+
formatter = self._formatters.get(col, self._default_formatter)
|
|
478
|
+
else:
|
|
479
|
+
formatter = self._default_formatter
|
|
480
|
+
return formatter(value)
|
|
481
|
+
|
|
482
|
+
@abstractmethod
|
|
483
|
+
def _create_rows(self) -> list[list[str]]:
|
|
484
|
+
pass
|
|
485
|
+
|
|
486
|
+
@staticmethod
|
|
487
|
+
def _validate_input_type(value, dtype):
|
|
488
|
+
if not isinstance(value, dtype):
|
|
489
|
+
raise TypeError(f"{value} must be a {dtype}")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
class GenericTable(Table):
|
|
493
|
+
"""
|
|
494
|
+
A generic table will take in any DataFrame and allow for easy formating and
|
|
495
|
+
column/index naming
|
|
496
|
+
"""
|
|
497
|
+
|
|
498
|
+
def __init__(self, df: pd.DataFrame):
|
|
499
|
+
self.df = df
|
|
500
|
+
self.ncolumns = df.shape[1]
|
|
501
|
+
self.columns = df.columns
|
|
502
|
+
self.nrows = df.shape[0]
|
|
503
|
+
self.reset_params()
|
|
504
|
+
|
|
505
|
+
def reset_params(self):
|
|
506
|
+
super().reset_params()
|
|
507
|
+
self.include_index = True
|
|
508
|
+
|
|
509
|
+
def _create_rows(self):
|
|
510
|
+
rows = []
|
|
511
|
+
for _index, row in self.df.iterrows():
|
|
512
|
+
_row = [self._index_labels.get(_index, _index)]
|
|
513
|
+
for col, value in zip(row.index, row.values):
|
|
514
|
+
formated_val = self._format_value(_index, col, value)
|
|
515
|
+
_row.append(formated_val)
|
|
516
|
+
if not self.include_index:
|
|
517
|
+
_row.pop(0)
|
|
518
|
+
rows.append(_row)
|
|
519
|
+
return rows
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class MeanDifferenceTable(Table):
|
|
523
|
+
def __init__(
|
|
524
|
+
self,
|
|
525
|
+
df: pd.DataFrame,
|
|
526
|
+
var_list: list,
|
|
527
|
+
group_var: str,
|
|
528
|
+
diff_pairs: list[tuple] = None,
|
|
529
|
+
alternative: str = "two-sided",
|
|
530
|
+
):
|
|
531
|
+
"""
|
|
532
|
+
Table that shows the difference in means between the specified groups in
|
|
533
|
+
the data. If there are only two groups, the table will show the difference
|
|
534
|
+
between the two.
|
|
535
|
+
|
|
536
|
+
Parameters
|
|
537
|
+
----------
|
|
538
|
+
df : pd.DataFrame
|
|
539
|
+
DataFrame containing the raw data to be compared
|
|
540
|
+
var_list : list
|
|
541
|
+
List of variables to compare means to between the groups
|
|
542
|
+
group_var : str
|
|
543
|
+
The variable in the data to group by
|
|
544
|
+
diff_pairs : list[tuple], optional
|
|
545
|
+
A list containing all of the pairs to take difference between. The
|
|
546
|
+
order they are listed in the tuple will be how they are subtracted.
|
|
547
|
+
If not specified, the difference between the two groups will be taken.
|
|
548
|
+
This must be specified when there are more than two groups.
|
|
549
|
+
alternative : str, optional
|
|
550
|
+
The alternative hypothesis for the t-test. It is a two-sided test
|
|
551
|
+
by default, but can be set to 'greater' or 'less' for a one-sided test.
|
|
552
|
+
For now, the same test is applied to each variable.
|
|
553
|
+
"""
|
|
554
|
+
# TODO: allow for grouping on multiple variables
|
|
555
|
+
self.groups = df[group_var].unique()
|
|
556
|
+
self.ngroups = len(self.groups)
|
|
557
|
+
self.var_list = var_list
|
|
558
|
+
if self.ngroups > 2 and not diff_pairs:
|
|
559
|
+
raise ValueError(
|
|
560
|
+
"diff_pairs must be provided if there are more than 2 groups"
|
|
561
|
+
)
|
|
562
|
+
if self.ngroups < 2:
|
|
563
|
+
raise ValueError("There must be at least two groups")
|
|
564
|
+
self.alternative = alternative
|
|
565
|
+
self.type_gdf = df.groupby(group_var)
|
|
566
|
+
self.grp_sizes = self.type_gdf.size()
|
|
567
|
+
self.grp_sizes["Overall Mean"] = df.shape[0]
|
|
568
|
+
self.means = self.type_gdf[var_list].mean().T
|
|
569
|
+
# add toal means column to means
|
|
570
|
+
self.means["Overall Mean"] = df[var_list].mean()
|
|
571
|
+
total_sem = df[var_list].sem()
|
|
572
|
+
total_sem.name = "Overall Mean"
|
|
573
|
+
self.sem = pd.merge(
|
|
574
|
+
self.type_gdf[var_list].sem().T,
|
|
575
|
+
total_sem,
|
|
576
|
+
left_index=True,
|
|
577
|
+
right_index=True,
|
|
578
|
+
)
|
|
579
|
+
self.diff_pairs = diff_pairs
|
|
580
|
+
self.ndiffs = len(self.diff_pairs) if self.diff_pairs else 1
|
|
581
|
+
self.t_stats = {}
|
|
582
|
+
self.pvalues = {}
|
|
583
|
+
self.reset_params()
|
|
584
|
+
self._get_diffs()
|
|
585
|
+
self.ncolumns = self.means.shape[1]
|
|
586
|
+
self.columns = self.means.columns
|
|
587
|
+
|
|
588
|
+
self.add_multicolumns(
|
|
589
|
+
["Means", "", "Differences"], [self.ngroups, 1, self.ndiffs]
|
|
590
|
+
) # may need to move this later if we make including the total mean optional
|
|
591
|
+
self.add_latex_line(
|
|
592
|
+
(
|
|
593
|
+
"\\cline{2-" + str(self.ngroups + 1) + "}"
|
|
594
|
+
"\\cline{"
|
|
595
|
+
+ str(self.ngroups + 3)
|
|
596
|
+
+ "-"
|
|
597
|
+
+ str(self.ncolumns + 1)
|
|
598
|
+
+ "}\\\\\n"
|
|
599
|
+
),
|
|
600
|
+
location="after-multicolumns",
|
|
601
|
+
) # this too
|
|
602
|
+
|
|
603
|
+
def reset_params(self):
|
|
604
|
+
super().reset_params()
|
|
605
|
+
self.show_n = True
|
|
606
|
+
self.show_standard_errors = True
|
|
607
|
+
self.p_values = [0.1, 0.05, 0.01]
|
|
608
|
+
self.include_index = True
|
|
609
|
+
self.show_stars = True
|
|
610
|
+
|
|
611
|
+
@property
|
|
612
|
+
def show_n(self) -> bool:
|
|
613
|
+
return self._show_n
|
|
614
|
+
|
|
615
|
+
@show_n.setter
|
|
616
|
+
def show_n(self, value: bool) -> None:
|
|
617
|
+
self._validate_input_type(value, bool)
|
|
618
|
+
self._show_n = value
|
|
619
|
+
|
|
620
|
+
@property
|
|
621
|
+
def show_standard_errors(self) -> bool:
|
|
622
|
+
return self._show_standard_errors
|
|
623
|
+
|
|
624
|
+
@show_standard_errors.setter
|
|
625
|
+
def show_standard_errors(self, value: bool) -> None:
|
|
626
|
+
self._validate_input_type(value, bool)
|
|
627
|
+
self._show_standard_errors = value
|
|
628
|
+
|
|
629
|
+
@property
|
|
630
|
+
def show_stars(self) -> bool:
|
|
631
|
+
return self._show_stars
|
|
632
|
+
|
|
633
|
+
@show_stars.setter
|
|
634
|
+
def show_stars(self, value: bool) -> None:
|
|
635
|
+
self._validate_input_type(value, bool)
|
|
636
|
+
self._show_stars = value
|
|
637
|
+
|
|
638
|
+
@staticmethod
|
|
639
|
+
def _render(render_func):
|
|
640
|
+
def wrapper(self, **kwargs):
|
|
641
|
+
if self.show_n:
|
|
642
|
+
self.add_line(
|
|
643
|
+
[
|
|
644
|
+
f"N={self.grp_sizes[c]:,}" if c in self.grp_sizes.index else ""
|
|
645
|
+
for c in self.means.columns
|
|
646
|
+
],
|
|
647
|
+
location="after-columns",
|
|
648
|
+
)
|
|
649
|
+
if self.show_stars:
|
|
650
|
+
_p = "p<"
|
|
651
|
+
if render_func.__name__ == "render_latex":
|
|
652
|
+
_p = "p$<$"
|
|
653
|
+
stars = ", ".join(
|
|
654
|
+
[
|
|
655
|
+
f"{'*' * i} {_p} {p}"
|
|
656
|
+
for i, p in enumerate(
|
|
657
|
+
sorted(self.p_values, reverse=True), start=1
|
|
658
|
+
)
|
|
659
|
+
]
|
|
660
|
+
)
|
|
661
|
+
note = f"Significance levels: {stars}"
|
|
662
|
+
self.add_note(note, alignment="r", escape=False)
|
|
663
|
+
output = render_func(self, **kwargs)
|
|
664
|
+
# remove all the supurflous lines that may not be needed in future renders
|
|
665
|
+
if self.show_n:
|
|
666
|
+
self.remove_line(location="after-columns", index=-1)
|
|
667
|
+
if self.show_stars:
|
|
668
|
+
self.remove_note(index=-1)
|
|
669
|
+
print("Note: Standard errors assume samples are drawn independently.")
|
|
670
|
+
return output
|
|
671
|
+
|
|
672
|
+
return wrapper
|
|
673
|
+
|
|
674
|
+
@_render
|
|
675
|
+
def render_latex(self, outfile=None, only_tabular=False) -> Union[str, None]:
|
|
676
|
+
return super().render_latex(outfile, only_tabular)
|
|
677
|
+
|
|
678
|
+
@_render
|
|
679
|
+
def render_html(self, outfile=None) -> Union[str, None]:
|
|
680
|
+
return super().render_html(outfile)
|
|
681
|
+
|
|
682
|
+
def _get_diffs(self):
|
|
683
|
+
# TODO: allow for standard errors caluclated under dependent samples
|
|
684
|
+
def sig_test(grp0, grp1, col):
|
|
685
|
+
se_list = []
|
|
686
|
+
for var in self.var_list:
|
|
687
|
+
_stat, pval = stats.ttest_ind(
|
|
688
|
+
grp0[var], grp1[var], equal_var=False, alternative=self.alternative
|
|
689
|
+
)
|
|
690
|
+
self.t_stats[f"{col}_{var}"] = _stat
|
|
691
|
+
self.pvalues[f"{col}_{var}"] = pval
|
|
692
|
+
s1 = grp0[var].std() ** 2
|
|
693
|
+
s2 = grp1[var].std() ** 2
|
|
694
|
+
n1 = grp0.shape[0]
|
|
695
|
+
n2 = grp1.shape[0]
|
|
696
|
+
se_list.append(np.sqrt(s1 / n1 + s2 / n2))
|
|
697
|
+
|
|
698
|
+
return pd.Series(se_list, index=self.var_list)
|
|
699
|
+
|
|
700
|
+
if self.diff_pairs is None:
|
|
701
|
+
self.means["Difference"] = (
|
|
702
|
+
self.means[self.groups[0]] - self.means[self.groups[1]]
|
|
703
|
+
)
|
|
704
|
+
grp0 = self.type_gdf.get_group(self.groups[0])
|
|
705
|
+
grp1 = self.type_gdf.get_group(self.groups[1])
|
|
706
|
+
ses = sig_test(grp0, grp1, "Difference")
|
|
707
|
+
ses.name = "Difference"
|
|
708
|
+
self.sem = self.sem.merge(ses, left_index=True, right_index=True)
|
|
709
|
+
else:
|
|
710
|
+
for pair in self.diff_pairs:
|
|
711
|
+
_col = f"{pair[0]} - {pair[1]}"
|
|
712
|
+
self.means[_col] = self.means[pair[0]] - self.means[pair[1]]
|
|
713
|
+
ses = sig_test(
|
|
714
|
+
self.type_gdf.get_group(pair[0]),
|
|
715
|
+
self.type_gdf.get_group(pair[1]),
|
|
716
|
+
_col,
|
|
717
|
+
)
|
|
718
|
+
ses.name = _col
|
|
719
|
+
self.sem = self.sem.merge(ses, left_index=True, right_index=True)
|
|
720
|
+
|
|
721
|
+
def _create_rows(self):
|
|
722
|
+
rows = []
|
|
723
|
+
for _index, row in self.means.iterrows():
|
|
724
|
+
sem_row = [""]
|
|
725
|
+
_row = [self._index_labels.get(_index, _index)]
|
|
726
|
+
for col, value in zip(row.index, row.values):
|
|
727
|
+
formated_val = self._format_value(_index, col, value)
|
|
728
|
+
if self.show_standard_errors:
|
|
729
|
+
try:
|
|
730
|
+
se = self.sem.loc[_index, col]
|
|
731
|
+
sem_row.append(f"({se:,.{self.sig_digits}f})")
|
|
732
|
+
except KeyError:
|
|
733
|
+
sem_row.append("")
|
|
734
|
+
if self.show_stars:
|
|
735
|
+
try:
|
|
736
|
+
pval = self.pvalues[f"{col}_{_index}"]
|
|
737
|
+
stars = pstars(pval, self.p_values)
|
|
738
|
+
except KeyError:
|
|
739
|
+
stars = ""
|
|
740
|
+
formated_val = f"{formated_val}{stars}"
|
|
741
|
+
_row.append(formated_val)
|
|
742
|
+
rows.append(_row)
|
|
743
|
+
if self.show_standard_errors:
|
|
744
|
+
rows.append(sem_row)
|
|
745
|
+
return rows
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
class SummaryTable(GenericTable):
|
|
749
|
+
def __init__(self, df: pd.DataFrame, var_list: list[str]):
|
|
750
|
+
summary_df = df[var_list].describe()
|
|
751
|
+
super().__init__(summary_df)
|
|
752
|
+
|
|
753
|
+
def reset_params(self) -> None:
|
|
754
|
+
super().reset_params()
|
|
755
|
+
self.rename_index(
|
|
756
|
+
{
|
|
757
|
+
"count": "Count",
|
|
758
|
+
"mean": "Mean",
|
|
759
|
+
"std": "Std. Dev.",
|
|
760
|
+
"min": "Min.",
|
|
761
|
+
"max": "Max.",
|
|
762
|
+
}
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
class ModelTable(Table):
|
|
767
|
+
"""
|
|
768
|
+
A table that can be used to show the results of most models passed in, with
|
|
769
|
+
enough user guidance.
|
|
770
|
+
"""
|
|
771
|
+
|
|
772
|
+
def __init__(
|
|
773
|
+
self,
|
|
774
|
+
models: list,
|
|
775
|
+
param_value_attr: str = "params",
|
|
776
|
+
param_names_attr: str = None,
|
|
777
|
+
std_err_attr: str = None,
|
|
778
|
+
ci_attr: str = None,
|
|
779
|
+
model_names: list[str] = None,
|
|
780
|
+
model_summary_vars: dict = None, # things like r2, T, etc.
|
|
781
|
+
):
|
|
782
|
+
self.models = models
|
|
783
|
+
self.ncolumns = len(models)
|
|
784
|
+
self.ci_attr = ci_attr
|
|
785
|
+
self.columns = model_names
|
|
786
|
+
self.std_err_attr = std_err_attr
|
|
787
|
+
# extract data from each model
|
|
788
|
+
self._model_dict = {}
|
|
789
|
+
self._param_names = []
|
|
790
|
+
for i, mod in enumerate(models):
|
|
791
|
+
_params_dict = {}
|
|
792
|
+
_stders_dict = {}
|
|
793
|
+
_cis_dict = {}
|
|
794
|
+
_params = self._get_attr(mod, param_value_attr)
|
|
795
|
+
_names = self._get_attr(mod, param_names_attr)
|
|
796
|
+
_stders = self._get_attr(mod, std_err_attr)
|
|
797
|
+
# _cis = self._get_attr(mod, ci_attr)
|
|
798
|
+
for _name, _param, stder in zip(_names, _params, _stders):
|
|
799
|
+
if _name not in self._param_names:
|
|
800
|
+
self._param_names.append(_name)
|
|
801
|
+
_params_dict[_name] = _param
|
|
802
|
+
_stders_dict[_name] = stder
|
|
803
|
+
# _cis_dict[_name] = ci
|
|
804
|
+
self._model_dict[i] = {"params": _params_dict, "std_errs": _stders_dict}
|
|
805
|
+
self.reset_params()
|
|
806
|
+
|
|
807
|
+
def reset_params(self) -> None:
|
|
808
|
+
super().reset_params()
|
|
809
|
+
self.include_index = True
|
|
810
|
+
self.show_model_nums = True
|
|
811
|
+
self._model_nums = [f"({i})" for i in range(1, len(self.models) + 1)]
|
|
812
|
+
# if no column names are provided use the model numbers
|
|
813
|
+
if self.columns is None:
|
|
814
|
+
self.columns = self._model_nums
|
|
815
|
+
self.show_model_nums = False
|
|
816
|
+
|
|
817
|
+
self.single_line = False
|
|
818
|
+
self.parameter_order = self._param_names
|
|
819
|
+
self.show_standard_errors = True
|
|
820
|
+
|
|
821
|
+
@property
|
|
822
|
+
def show_model_nums(self) -> bool:
|
|
823
|
+
"""
|
|
824
|
+
If true, model numbers are included under the column names
|
|
825
|
+
"""
|
|
826
|
+
return self._show_model_nums
|
|
827
|
+
|
|
828
|
+
@show_model_nums.setter
|
|
829
|
+
def show_model_nums(self, value: bool) -> None:
|
|
830
|
+
self._validate_input_type(value, bool)
|
|
831
|
+
self._show_model_nums = value
|
|
832
|
+
|
|
833
|
+
@property
|
|
834
|
+
def single_row(self) -> bool:
|
|
835
|
+
"""
|
|
836
|
+
If true, the significance variable (either standard error or confience interval)
|
|
837
|
+
will be rendered on the same line as the parameter value
|
|
838
|
+
"""
|
|
839
|
+
return self._single_row
|
|
840
|
+
|
|
841
|
+
@single_row.setter
|
|
842
|
+
def single_row(self, value: bool) -> None:
|
|
843
|
+
self._validate_input_type(value, bool)
|
|
844
|
+
self._single_row = value
|
|
845
|
+
|
|
846
|
+
@property
|
|
847
|
+
def columns(self) -> list:
|
|
848
|
+
return self._columns
|
|
849
|
+
|
|
850
|
+
@columns.setter
|
|
851
|
+
def columns(self, column_labels: list[str]) -> None:
|
|
852
|
+
if not column_labels is None:
|
|
853
|
+
if not isinstance(column_labels, list):
|
|
854
|
+
raise TypeError("Column labels must be a list of strings")
|
|
855
|
+
for col in column_labels:
|
|
856
|
+
if not isinstance(col, str):
|
|
857
|
+
raise ValueError("All column labels must be strings")
|
|
858
|
+
self._columns = column_labels
|
|
859
|
+
|
|
860
|
+
@property
|
|
861
|
+
def parameter_order(self) -> list:
|
|
862
|
+
return self._parameter_order
|
|
863
|
+
|
|
864
|
+
@parameter_order.setter
|
|
865
|
+
def parameter_order(self, order: list) -> None:
|
|
866
|
+
if not order is None:
|
|
867
|
+
if not isinstance(order, list):
|
|
868
|
+
raise TypeError("Parameter order must be a list of strings")
|
|
869
|
+
for param in order:
|
|
870
|
+
if not isinstance(param, str):
|
|
871
|
+
raise ValueError("All parameter names must be strings")
|
|
872
|
+
self._parameter_order = order
|
|
873
|
+
|
|
874
|
+
@property
|
|
875
|
+
def show_standard_errors(self) -> bool:
|
|
876
|
+
return self._show_standard_errors
|
|
877
|
+
|
|
878
|
+
@show_standard_errors.setter
|
|
879
|
+
def show_standard_errors(self, value: bool) -> None:
|
|
880
|
+
self._validate_input_type(value, bool)
|
|
881
|
+
self._show_standard_errors = value
|
|
882
|
+
|
|
883
|
+
def _create_rows(self):
|
|
884
|
+
rows = []
|
|
885
|
+
for param in self.parameter_order:
|
|
886
|
+
_row = [self._index_labels.get(param, param)]
|
|
887
|
+
se_row = [""]
|
|
888
|
+
for col, mod in zip(self.columns, self._model_dict.values()):
|
|
889
|
+
param_val = mod["params"].get(param, "")
|
|
890
|
+
se_val = mod["std_errs"].get(param, "")
|
|
891
|
+
_row.append(self._format_value(param, col, param_val))
|
|
892
|
+
if isinstance(se_val, str):
|
|
893
|
+
se_row.append(se_val)
|
|
894
|
+
else:
|
|
895
|
+
se_row.append(f"({se_val:,.{self.sig_digits}f})")
|
|
896
|
+
rows.append(_row)
|
|
897
|
+
if self.show_standard_errors:
|
|
898
|
+
rows.append(se_row)
|
|
899
|
+
|
|
900
|
+
return rows
|
|
901
|
+
|
|
902
|
+
@staticmethod
|
|
903
|
+
def _get_attr(mod, attrstr):
|
|
904
|
+
if "." in attrstr:
|
|
905
|
+
attrs = attrstr.split(".")
|
|
906
|
+
for a in attrs:
|
|
907
|
+
mod = getattr(mod, a)
|
|
908
|
+
else:
|
|
909
|
+
mod = getattr(mod, attrstr)
|
|
910
|
+
return mod
|
|
911
|
+
|
|
912
|
+
@staticmethod
|
|
913
|
+
def _render(render_func):
|
|
914
|
+
def wrapper(self, **kwargs):
|
|
915
|
+
if self.show_model_nums:
|
|
916
|
+
self.add_line(self._model_nums, location="after-columns")
|
|
917
|
+
output = render_func(self, **kwargs)
|
|
918
|
+
if self.show_model_nums:
|
|
919
|
+
self.remove_line(location="after-columns", index=-1)
|
|
920
|
+
return output
|
|
921
|
+
|
|
922
|
+
return wrapper
|
|
923
|
+
|
|
924
|
+
@_render
|
|
925
|
+
def render_latex(self, outfile=None, only_tabular=False) -> Union[str, None]:
|
|
926
|
+
return super().render_latex(outfile, only_tabular)
|
|
927
|
+
|
|
928
|
+
@_render
|
|
929
|
+
def render_html(self, outfile=None) -> Union[str, None]:
|
|
930
|
+
return super().render_html(outfile)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
class PanelTable:
|
|
934
|
+
"""
|
|
935
|
+
Merge two tables together. Not implemented yet
|
|
936
|
+
"""
|
|
937
|
+
|
|
938
|
+
def __init__(self, panels: list[Table]):
|
|
939
|
+
pass
|