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/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