pysofra 0.1.0a4__tar.gz → 0.1.0a7__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.
Files changed (108) hide show
  1. pysofra-0.1.0a7/CHANGELOG.md +157 -0
  2. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/PKG-INFO +6 -6
  3. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/README.md +5 -5
  4. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/pyproject.toml +1 -1
  5. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/__init__.py +1 -1
  6. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/core/table.py +43 -0
  7. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/models/extract.py +26 -4
  8. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/models/pool.py +47 -8
  9. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/models/regression.py +12 -1
  10. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/models/survival.py +28 -0
  11. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/plot/forest.py +23 -0
  12. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/summary/effect_size.py +23 -2
  13. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/summary/extras.py +187 -32
  14. pysofra-0.1.0a7/src/pysofra/summary/smd.py +246 -0
  15. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/summary/tbl_one.py +69 -16
  16. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/summary/tests.py +215 -51
  17. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/fixtures/scipy_validation/svyttest.json +5 -5
  18. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_regressions.py +501 -4
  19. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_render_edges.py +15 -7
  20. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_summary_edges.py +2 -2
  21. pysofra-0.1.0a4/CHANGELOG.md +0 -55
  22. pysofra-0.1.0a4/src/pysofra/summary/smd.py +0 -133
  23. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/.gitignore +0 -0
  24. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/LICENSE +0 -0
  25. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/NOTICE +0 -0
  26. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/core/__init__.py +0 -0
  27. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/core/compose.py +0 -0
  28. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/core/format.py +0 -0
  29. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/core/frames.py +0 -0
  30. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/core/schema.py +0 -0
  31. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/models/__init__.py +0 -0
  32. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/models/uvregression.py +0 -0
  33. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/plot/__init__.py +0 -0
  34. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/plot/_backend.py +0 -0
  35. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/plot/inline.py +0 -0
  36. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/plot/km.py +0 -0
  37. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/render/__init__.py +0 -0
  38. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/render/_zip_determinism.py +0 -0
  39. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/render/base.py +0 -0
  40. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/render/docx.py +0 -0
  41. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/render/html.py +0 -0
  42. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/render/image.py +0 -0
  43. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/render/latex.py +0 -0
  44. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/render/markdown.py +0 -0
  45. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/render/pptx.py +0 -0
  46. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/render/xlsx.py +0 -0
  47. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/summary/__init__.py +0 -0
  48. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/summary/calibrate.py +0 -0
  49. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/summary/design.py +0 -0
  50. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/summary/stats.py +0 -0
  51. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/summary/tbl_cross.py +0 -0
  52. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/summary/tbl_summary.py +0 -0
  53. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/summary/typing.py +0 -0
  54. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/summary/weights.py +0 -0
  55. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/themes/__init__.py +0 -0
  56. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/src/pysofra/themes/registry.py +0 -0
  57. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/conftest.py +0 -0
  58. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/fixtures/scipy_validation/README.md +0 -0
  59. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/fixtures/scipy_validation/anova_oneway.json +0 -0
  60. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/fixtures/scipy_validation/chi_square.json +0 -0
  61. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/fixtures/scipy_validation/fisher_2x2.json +0 -0
  62. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/fixtures/scipy_validation/kruskal_wallis.json +0 -0
  63. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/fixtures/scipy_validation/student_t.json +0 -0
  64. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/fixtures/scipy_validation/weighted_mean.json +0 -0
  65. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/fixtures/scipy_validation/welch_t_test.json +0 -0
  66. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/fixtures/scipy_validation/wilcoxon_rank_sum.json +0 -0
  67. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_api_stability.py +0 -0
  68. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_compose.py +0 -0
  69. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_compose_edges.py +0 -0
  70. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_conditional_formatting.py +0 -0
  71. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_design_regression.py +0 -0
  72. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_extract_edges.py +0 -0
  73. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_extras_edges.py +0 -0
  74. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_extras_edges_2.py +0 -0
  75. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_format.py +0 -0
  76. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_latex_pptx.py +0 -0
  77. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_misc_fixes.py +0 -0
  78. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_modifier_edges.py +0 -0
  79. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_multi_model.py +0 -0
  80. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_partial_modifiers.py +0 -0
  81. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_partials.py +0 -0
  82. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_plot_determinism.py +0 -0
  83. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_plot_embedding.py +0 -0
  84. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_plots.py +0 -0
  85. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_polars.py +0 -0
  86. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_pptx_overflow.py +0 -0
  87. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_property_invariants.py +0 -0
  88. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_rao_scott.py +0 -0
  89. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_regression.py +0 -0
  90. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_render_edges_2.py +0 -0
  91. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_renderer_consistency.py +0 -0
  92. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_rendering.py +0 -0
  93. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_scipy_validation.py +0 -0
  94. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_snapshot.py +0 -0
  95. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_statistical_correctness.py +0 -0
  96. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_stats.py +0 -0
  97. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_summary_edges_2.py +0 -0
  98. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_survey_design.py +0 -0
  99. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_survey_extensions.py +0 -0
  100. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_survival.py +0 -0
  101. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_table_edges.py +0 -0
  102. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_tbl_one.py +0 -0
  103. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_test_overrides.py +0 -0
  104. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_uvregression_factors.py +0 -0
  105. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_validation_fixes.py +0 -0
  106. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_weights.py +0 -0
  107. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_wishlist.py +0 -0
  108. {pysofra-0.1.0a4 → pysofra-0.1.0a7}/tests/test_xlsx.py +0 -0
@@ -0,0 +1,157 @@
1
+ # Changelog
2
+
3
+ All notable changes to PySofra will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0a7] — 2026-05-26
9
+
10
+ ### Fixed
11
+ - **`tbl_survival` validates `time` and `event` content**: negative
12
+ survival times raise `ValueError`; non-`0/1` event codes raise
13
+ `ValueError`. Previously these were passed silently to lifelines,
14
+ which would either clamp negative times to zero or treat any
15
+ nonzero event value as a death — producing a misleading curve
16
+ without complaint.
17
+ - **`add_global_p()` on weighted `tbl_one`** now uses
18
+ ``statsmodels.GLM(..., var_weights=w)`` instead of
19
+ ``freq_weights=w``. For non-integer sampling weights ``freq_weights``
20
+ scales ``df_resid`` by ``Σw`` (treating the weight as an integer
21
+ count of repeats), which inflates the effective sample size and
22
+ produces anti-conservative p-values. ``var_weights`` keeps
23
+ ``df_resid = n − k`` — the appropriate SRS-weighted Wald-F
24
+ convention. For full design-based inference (with strata or
25
+ clusters) use ``ps.SurveyDesign`` end-to-end.
26
+
27
+ ### Changed
28
+ - **`rao_scott_chisq` docstring** now honestly states a 10–15%
29
+ typical disagreement with R ``survey::svychisq`` on non-trivial
30
+ weighted designs (was: an overoptimistic "~5%"). The first-order
31
+ Kish-DEFF approximation is unchanged; for design-grade chi-square
32
+ inference call R directly.
33
+ - **Added published-reference citations** to public statistical
34
+ functions: Welch / Satterthwaite, Wilcoxon (Mann-Whitney 1947),
35
+ Kruskal-Wallis (1952), Fisher (1922), Pearson chi-square (1900),
36
+ Wilson score (1927), Rao-Scott (1981/1984), Kish (1965),
37
+ Benjamini-Hochberg (1995), Benjamini-Yekutieli (2001), Holm
38
+ (1979), Hommel (1988), Šidák (1967), Binder (1983) Taylor
39
+ linearisation.
40
+ - **`pool` and `cohen_d` docstrings** now have NumPy-style
41
+ ``Parameters`` / ``Returns`` / ``References`` sections matching
42
+ the other public functions.
43
+
44
+ ## [0.1.0a6] — 2026-05-26
45
+
46
+ ### Fixed
47
+ - **`svyttest` now uses full-design Taylor linearisation** of the
48
+ regression coefficient `ȳ_B − ȳ_A` instead of summing per-group
49
+ variances in quadrature. The new formulation accounts for
50
+ cross-group covariance under the survey design. Pinned against
51
+ R `survey::svyttest`: identical t-statistic and df, p-value
52
+ agreement to 7 decimal places on the test fixture. The previous
53
+ per-group formulation could be wildly anti-conservative when
54
+ clusters straddled groups.
55
+ - **`svyttest` degrees of freedom** corrected to `n_PSU − n_strata − 1`
56
+ (the design df minus one for the slope parameter). Previously
57
+ off by one.
58
+ - **`rao_scott_chisq` normalises weights to `Σw = n` before computing
59
+ the chi-square statistic**, matching R `survey::svychisq`. The
60
+ previous formulation produced statistics that scaled linearly with
61
+ the absolute magnitude of the weights and disagreed with R by
62
+ ~10–15% on typical survey-weighted contingency tables.
63
+ - **`tbl_one(..., weights=...)` raises on negative or all-zero
64
+ weights** instead of warning and silently dropping. The earlier
65
+ behaviour could leave `N = -1` or `N = 0` cells in the rendered
66
+ table.
67
+ - **`tbl_one(...).add_p()` now emits a UserWarning** when falling
68
+ back to unweighted ANOVA / Kruskal–Wallis for >2-group
69
+ continuous variables under weights (design-adjusted multi-group
70
+ test is not yet implemented).
71
+ - **`tbl_one(...).add_global_p()` warns** when the table already
72
+ carries a column added by a prior modifier (`add_difference`,
73
+ `add_significance_stars`); the rebuild path drops such columns
74
+ and the user should call `add_global_p()` first.
75
+
76
+ ## [0.1.0a5] — 2026-05-25
77
+
78
+ ### Fixed
79
+ - **`svyttest` degrees of freedom** now follow the standard survey
80
+ convention `n_PSU − n_strata` (matching Stata `svy: ttest` and R
81
+ `survey::svyttest` with `nest=TRUE`), instead of `N − n_strata`. The
82
+ previous formula over-stated df dramatically under clustering and
83
+ produced anti-conservative p-values.
84
+ - **AFT models (Weibull / LogNormal / LogLogistic) are now labelled
85
+ "TR" (Time Ratio)** instead of "HR". The two parameters point in
86
+ opposite directions (TR > 1 → longer survival; HR > 1 → shorter
87
+ survival), so the mislabel was potentially misleading.
88
+ - **Lifelines regression CIs honour the user-supplied `conf_level`**.
89
+ Previously the CIs reflected the model's fit-time `alpha` regardless
90
+ of `conf_level`, so passing `conf_level=0.90` produced a "90% CI"
91
+ header with 95% CI numbers. The CI is now re-derived from `coef ±
92
+ z·se(coef)` at the requested level.
93
+ - **SMDs on a weighted Table 1 are now weighted**. `continuous_smd` and
94
+ `categorical_smd` accept a `weights=` argument; `tbl_one(..., weights=)`
95
+ threads it through automatically. Previously the SMD column was
96
+ always computed on unweighted samples even on a weighted table.
97
+ - **`add_ci`, `add_difference`, and `add_global_p` now honour weights**.
98
+ The Welch CI on continuous means, the Newcombe CI on proportion
99
+ differences, and the joint Wald-F test for `add_global_p` all use
100
+ weighted means / variances / proportions (with Kish's effective
101
+ sample size for SEs) when the table was built with `weights=`.
102
+
103
+ ### Added
104
+ - `conf_level` range validation in `tbl_regression`, `tbl_survival`, and
105
+ `pool` (raises `ValueError` for values outside `(0, 1)`).
106
+ - `with_forest_plot()` on a multi-model regression table now emits a
107
+ `UserWarning` that only the first model is visualised, so the
108
+ presence of additional models is no longer silent.
109
+
110
+ ## [0.1.0a4] — 2026-05-25
111
+
112
+ ### Added
113
+ - Input validation for duplicate names in `variables=` (now raises
114
+ `ValueError` instead of silently accepting duplicates).
115
+ - Confidence-level range check in `.add_ci()` and related modifiers
116
+ (must lie in `(0, 1)`).
117
+
118
+ ### Changed
119
+ - Renamed several test files for clarity. No public API changes.
120
+
121
+ ## [0.1.0a3] — 2026-05-24
122
+
123
+ ### Changed
124
+ - Documentation polish across README, changelog, and inline docstrings.
125
+ No public API or behavioural changes.
126
+
127
+ ## [0.1.0a2] — 2026-05-23
128
+
129
+ ### Fixed
130
+ - Theme styling now survives notebook viewers that strip `<style>` blocks
131
+ (e.g. GitHub's notebook viewer). Critical theme properties (font, border,
132
+ padding) are emitted as inline `style` attributes on each table element, so
133
+ `jama` vs `nejm` vs `clinical` vs `minimal` stay visibly distinct everywhere.
134
+ - README image and link URLs are now absolute so they render on PyPI.
135
+
136
+ ## [0.1.0a1] — 2026-05-20
137
+
138
+ ### Added
139
+
140
+ - Initial alpha release.
141
+ - Core `SofraTable` object with immutable method chaining.
142
+ - `tbl_one()` — baseline characteristic tables (Table 1) with continuous /
143
+ categorical summaries, stratification, missing data summaries, overall
144
+ column, p-values, and standardized mean differences (SMDs).
145
+ - `tbl_summary()` — general descriptive summary tables with grouping and
146
+ configurable statistics.
147
+ - `tbl_regression()` — regression tables for `statsmodels` linear / logistic
148
+ / Poisson models, with confidence intervals, exponentiation, and p-values.
149
+ - `tbl_merge()` / `tbl_stack()` — table composition.
150
+ - HTML renderer with rich notebook `_repr_html_` output (dark-mode aware,
151
+ responsive, sticky headers).
152
+ - Markdown renderer.
153
+ - DOCX renderer via `python-docx` (publication-quality Word tables with
154
+ captions, footnotes, merged spanning headers).
155
+ - Themes: `clinical`, `compact`, `jama`, `nejm`, `minimal`.
156
+ - Automatic statistical test selection with override hooks.
157
+ - Snapshot tests for HTML output.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pysofra
3
- Version: 0.1.0a4
3
+ Version: 0.1.0a7
4
4
  Summary: Statistical reporting and table preparation framework for Python — the missing reporting layer.
5
5
  Project-URL: Homepage, https://github.com/jturner-uofl/pysofra
6
6
  Project-URL: Documentation, https://github.com/jturner-uofl/pysofra
@@ -70,12 +70,12 @@ Description-Content-Type: text/markdown
70
70
 
71
71
  ### The missing statistical reporting layer for Python
72
72
 
73
- [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/jturner-uofl/pysofra)
73
+ [![Coverage](https://img.shields.io/badge/coverage-%E2%89%A599%25-brightgreen.svg)](https://github.com/jturner-uofl/pysofra)
74
74
  [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue.svg)](https://www.python.org/downloads/)
75
75
  [![License: GPL-3.0+](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](https://github.com/jturner-uofl/pysofra/blob/main/LICENSE)
76
76
  [![Style: ruff](https://img.shields.io/badge/style-ruff-purple.svg)](https://github.com/astral-sh/ruff)
77
77
  [![Types: mypy strict](https://img.shields.io/badge/types-mypy%20strict-blue.svg)](http://mypy-lang.org/)
78
- [![Tests: 886](https://img.shields.io/badge/tests-886%20passing-brightgreen.svg)](#status)
78
+ [![Tests: 906](https://img.shields.io/badge/tests-906%20passing-brightgreen.svg)](#status)
79
79
 
80
80
  </div>
81
81
 
@@ -111,7 +111,7 @@ Description-Content-Type: text/markdown
111
111
  - **One immutable object, seven output formats** — build a `SofraTable` once, render to HTML / Markdown / LaTeX / DOCX / PPTX / XLSX / PNG, all byte-deterministic across processes
112
112
  - **Auto-dispatched statistical tests** — Welch, Wilcoxon, ANOVA, Kruskal–Wallis, Fisher, χ², Rao–Scott, design-adjusted *t* — picked by variable kind, overridable per-row
113
113
  - **Inline forest plots and KM curves** — embed matplotlib figures directly into the table; the same `SofraTable` renders them across every backend
114
- - **Statistically correct** — every numeric output validated against `scipy` / `statsmodels` / `lifelines` at machine precision, with cross-checks against R's `gtsummary`
114
+ - **Statistically correct** — every numeric output validated against `scipy` / `statsmodels` / `lifelines` reference implementations at machine precision
115
115
  - **Method-chainable and immutable** — every modifier returns a new table; no in-place mutation, no global state, fully reproducible
116
116
 
117
117
  <div align="center">
@@ -255,13 +255,13 @@ pip install "pysofra[dev]" # testing + linting (pytest, ruff, mypy, hypot
255
255
 
256
256
  ## Status
257
257
 
258
- PySofra is in **alpha** (`0.1.0a4`). The public API surface is pinned
258
+ PySofra is in **alpha** (`0.1.0a7`). The public API surface is pinned
259
259
  by an explicit
260
260
  [API-stability test](https://github.com/jturner-uofl/pysofra/blob/main/tests/test_api_stability.py)
261
261
  so that any unintended rename, removal, or signature change surfaces as
262
262
  a failed test. Quality bar at this release:
263
263
 
264
- * **More than 800 tests passing**, **100% line coverage**, mypy strict, ruff clean.
264
+ * **900+ tests passing**, near-100% line coverage, mypy strict, ruff clean.
265
265
  * Every numeric output is validated against `scipy`, `lifelines`,
266
266
  `statsmodels`, or a hand-computed textbook formula
267
267
  ([test_statistical_correctness.py](https://github.com/jturner-uofl/pysofra/blob/main/tests/test_statistical_correctness.py)).
@@ -4,12 +4,12 @@
4
4
 
5
5
  ### The missing statistical reporting layer for Python
6
6
 
7
- [![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/jturner-uofl/pysofra)
7
+ [![Coverage](https://img.shields.io/badge/coverage-%E2%89%A599%25-brightgreen.svg)](https://github.com/jturner-uofl/pysofra)
8
8
  [![Python](https://img.shields.io/badge/python-3.11%20%7C%203.12%20%7C%203.13-blue.svg)](https://www.python.org/downloads/)
9
9
  [![License: GPL-3.0+](https://img.shields.io/badge/license-GPL--3.0--or--later-blue.svg)](https://github.com/jturner-uofl/pysofra/blob/main/LICENSE)
10
10
  [![Style: ruff](https://img.shields.io/badge/style-ruff-purple.svg)](https://github.com/astral-sh/ruff)
11
11
  [![Types: mypy strict](https://img.shields.io/badge/types-mypy%20strict-blue.svg)](http://mypy-lang.org/)
12
- [![Tests: 886](https://img.shields.io/badge/tests-886%20passing-brightgreen.svg)](#status)
12
+ [![Tests: 906](https://img.shields.io/badge/tests-906%20passing-brightgreen.svg)](#status)
13
13
 
14
14
  </div>
15
15
 
@@ -45,7 +45,7 @@
45
45
  - **One immutable object, seven output formats** — build a `SofraTable` once, render to HTML / Markdown / LaTeX / DOCX / PPTX / XLSX / PNG, all byte-deterministic across processes
46
46
  - **Auto-dispatched statistical tests** — Welch, Wilcoxon, ANOVA, Kruskal–Wallis, Fisher, χ², Rao–Scott, design-adjusted *t* — picked by variable kind, overridable per-row
47
47
  - **Inline forest plots and KM curves** — embed matplotlib figures directly into the table; the same `SofraTable` renders them across every backend
48
- - **Statistically correct** — every numeric output validated against `scipy` / `statsmodels` / `lifelines` at machine precision, with cross-checks against R's `gtsummary`
48
+ - **Statistically correct** — every numeric output validated against `scipy` / `statsmodels` / `lifelines` reference implementations at machine precision
49
49
  - **Method-chainable and immutable** — every modifier returns a new table; no in-place mutation, no global state, fully reproducible
50
50
 
51
51
  <div align="center">
@@ -189,13 +189,13 @@ pip install "pysofra[dev]" # testing + linting (pytest, ruff, mypy, hypot
189
189
 
190
190
  ## Status
191
191
 
192
- PySofra is in **alpha** (`0.1.0a4`). The public API surface is pinned
192
+ PySofra is in **alpha** (`0.1.0a7`). The public API surface is pinned
193
193
  by an explicit
194
194
  [API-stability test](https://github.com/jturner-uofl/pysofra/blob/main/tests/test_api_stability.py)
195
195
  so that any unintended rename, removal, or signature change surfaces as
196
196
  a failed test. Quality bar at this release:
197
197
 
198
- * **More than 800 tests passing**, **100% line coverage**, mypy strict, ruff clean.
198
+ * **900+ tests passing**, near-100% line coverage, mypy strict, ruff clean.
199
199
  * Every numeric output is validated against `scipy`, `lifelines`,
200
200
  `statsmodels`, or a hand-computed textbook formula
201
201
  ([test_statistical_correctness.py](https://github.com/jturner-uofl/pysofra/blob/main/tests/test_statistical_correctness.py)).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pysofra"
7
- version = "0.1.0a4"
7
+ version = "0.1.0a7"
8
8
  description = "Statistical reporting and table preparation framework for Python — the missing reporting layer."
9
9
  readme = "README.md"
10
10
  license = { text = "GPL-3.0-or-later" }
@@ -50,7 +50,7 @@ from .summary.tbl_summary import tbl_summary
50
50
  from .summary.tests import available_tests
51
51
  from .themes.registry import available_themes, register_theme
52
52
 
53
- __version__ = "0.1.0a4"
53
+ __version__ = "0.1.0a7"
54
54
 
55
55
  __all__ = [
56
56
  "CellPart",
@@ -262,6 +262,23 @@ class SofraTable:
262
262
  ``fdr_bh`` (Benjamini–Hochberg, default), ``fdr_by``,
263
263
  ``bonferroni``, ``holm``, ``hommel``, ``sidak``. Implicitly
264
264
  enables p-values when not already on.
265
+
266
+ References
267
+ ----------
268
+ Benjamini, Y., & Hochberg, Y. (1995). Controlling the false
269
+ discovery rate: a practical and powerful approach to multiple
270
+ testing. *J. R. Stat. Soc. B*, 57(1), 289–300. (``fdr_bh``)
271
+ Benjamini, Y., & Yekutieli, D. (2001). The control of the
272
+ false discovery rate in multiple testing under dependency.
273
+ *Ann. Stat.*, 29(4), 1165–1188. (``fdr_by``)
274
+ Holm, S. (1979). A simple sequentially rejective multiple test
275
+ procedure. *Scand. J. Stat.*, 6(2), 65–70. (``holm``)
276
+ Hommel, G. (1988). A stagewise rejective multiple test
277
+ procedure based on a modified Bonferroni test. *Biometrika*,
278
+ 75(2), 383–386. (``hommel``)
279
+ Šidák, Z. (1967). Rectangular confidence regions for the
280
+ means of multivariate normal distributions. *J. Am. Stat.
281
+ Assoc.*, 62(318), 626–633. (``sidak``)
265
282
  """
266
283
  return self._with_option(p_value=True, q_value=True, q_method=method)
267
284
 
@@ -355,8 +372,34 @@ class SofraTable:
355
372
 
356
373
  return add_global_p(self)
357
374
  # tbl_one / tbl_summary path: route through the rebuild spec.
375
+ # The rebuild reconstructs the table from spec.options only;
376
+ # columns added by post-build modifiers (``add_difference``,
377
+ # ``add_ci``, ``add_significance_stars``, ...) live in
378
+ # ``self.rows``/``self.headers`` and are NOT preserved by the
379
+ # rebuild. Detect a *known* such column by header text and warn
380
+ # the user so the silent column-drop doesn't mislead them.
381
+ # The correct chaining order is to call ``add_global_p()``
382
+ # *before* any column-adding modifier.
358
383
  spec = self._spec
359
384
  if spec is not None and spec.builder in ("tbl_one", "tbl_summary"):
385
+ post_build_headers = {"Diff", "[", "[ "}
386
+ header_texts = (
387
+ [c.text for c in self.headers[0].cells] if self.headers else []
388
+ )
389
+ has_diff_col = any(h.startswith("Diff (") for h in header_texts)
390
+ has_sig_col = any(h.lower() == "signif." for h in header_texts)
391
+ del post_build_headers
392
+ if has_diff_col or has_sig_col:
393
+ import warnings as _w
394
+ _w.warn(
395
+ "add_global_p() reruns the table builder; any "
396
+ "column added by a prior modifier (e.g. add_difference, "
397
+ "add_significance_stars) will be dropped. Call "
398
+ "add_global_p() BEFORE those modifiers to preserve "
399
+ "their columns.",
400
+ UserWarning,
401
+ stacklevel=2,
402
+ )
360
403
  return self._with_option(
361
404
  global_p=True,
362
405
  global_p_adjust_for=tuple(adjust_for or ()),
@@ -152,11 +152,29 @@ def _extract_lifelines(model: Any, conf_level: float) -> ModelSummary:
152
152
  )
153
153
 
154
154
  estimates = summary["coef"].astype(float)
155
- ci_lo = summary[lo_col].astype(float)
156
- ci_hi = summary[hi_col].astype(float)
157
155
  pvalues = summary["p"].astype(float) if "p" in summary.columns else pd.Series(
158
156
  [float("nan")] * len(summary), index=summary.index
159
157
  )
158
+
159
+ # Lifelines bakes the CI level into the fit (alpha=0.05 by default),
160
+ # so the ``coef lower/upper X%`` columns reflect the fit-time alpha,
161
+ # not the user's requested ``conf_level``. To honour ``conf_level``
162
+ # without re-fitting the model, re-derive the CI directly from
163
+ # ``coef`` and ``se(coef)`` using a normal pivot. Falls back to the
164
+ # lifelines-provided columns only when no SE column is present.
165
+ se_col = _find_col(summary, ["se(coef)"])
166
+ if se_col is not None:
167
+ import numpy as _np
168
+ from scipy import stats as _sp_stats
169
+ z = float(_sp_stats.norm.ppf(0.5 + conf_level / 2))
170
+ se = summary[se_col].astype(float)
171
+ ci_lo = estimates - z * se
172
+ ci_hi = estimates + z * se
173
+ # Hide ``_np`` reference so linters don't flag it as unused.
174
+ del _np
175
+ else:
176
+ ci_lo = summary[lo_col].astype(float)
177
+ ci_hi = summary[hi_col].astype(float)
160
178
  # AFT models (Weibull / log-logistic / log-normal) carry a MultiIndex
161
179
  # ``(param, covariate)`` index — e.g. ``('lambda_', 'age')``. Renderers
162
180
  # expect string row labels; flatten with ``covariate (param)`` so the
@@ -170,9 +188,13 @@ def _extract_lifelines(model: Any, conf_level: float) -> ModelSummary:
170
188
  pvalues.index = pd.Index(flat)
171
189
 
172
190
  family = type(model).__name__
173
- # Cox / Weibull / log-normal AFT all naturally report exp(coef) = HR.
191
+ # Cox returns exp(coef) as a Hazard Ratio; the AFT family (Weibull,
192
+ # LogNormal, LogLogistic) returns exp(coef) as a Time Ratio. Both are
193
+ # the natural "exponentiate me" output of the fitter, so we set
194
+ # natural_exp=True; the column header label is chosen downstream by
195
+ # ``_default_estimate_label`` in regression.py which selects "HR"
196
+ # for Cox and "TR" for AFT.
174
197
  natural_exp = True
175
- del conf_level # honoured by lifelines at fit time
176
198
  return ModelSummary(
177
199
  estimates=estimates,
178
200
  ci_lo=ci_lo,
@@ -45,15 +45,54 @@ from .extract import ModelSummary, extract
45
45
  def pool(models: list[Any], *, conf_level: float = 0.95) -> ModelSummary:
46
46
  """Pool a list of fitted models via Rubin's rules.
47
47
 
48
- Returns a :class:`ModelSummary` whose estimates / CIs / p-values
49
- reflect the across-imputation combination. Pass the result directly
50
- into :func:`pysofra.tbl_regression`.
51
-
52
- Each input must be a fitted model recognised by
53
- :func:`pysofra.models.extract.extract` — statsmodels, lifelines,
54
- sklearn (sklearn has no SEs so the pool degenerates to a simple
55
- mean-of-coefficients).
48
+ Parameters
49
+ ----------
50
+ models
51
+ A list of two or more fitted models, each fit on a separate
52
+ imputed dataset. Every model must be one of the families
53
+ recognised by :func:`pysofra.models.extract.extract` —
54
+ statsmodels (Logit, OLS, GLM, Poisson), lifelines
55
+ (CoxPHFitter, AFT family), or scikit-learn linear models.
56
+ All models in the list must share the same coefficient names.
57
+ conf_level
58
+ Confidence level for the pooled CIs, in the open interval
59
+ ``(0, 1)``. Default 0.95.
60
+
61
+ Returns
62
+ -------
63
+ ModelSummary
64
+ A summary whose ``estimates``, ``ci_lo``, ``ci_hi`` and
65
+ ``pvalues`` reflect Rubin's-rule pooling across the
66
+ imputed-dataset fits. Pass this directly into
67
+ :func:`pysofra.tbl_regression` to render a pooled regression
68
+ table.
69
+
70
+ Notes
71
+ -----
72
+ The pooled point estimate is the across-imputation mean of the
73
+ per-imputation estimates. The total variance ``T = Ū + (1 + 1/m)·B``
74
+ combines the average within-imputation variance ``Ū`` and the
75
+ between-imputation variance ``B`` (with the small-sample
76
+ correction ``1 + 1/m``). Confidence intervals use a *t*
77
+ distribution with Rubin's original degrees-of-freedom
78
+ ``df = (m − 1)·(1 + Ū / ((1 + 1/m)·B))²``. The newer
79
+ Barnard–Rubin (1999) df refinement is not yet implemented; for
80
+ very small per-imputation df it slightly narrows the CI relative
81
+ to ``mice::pool``.
82
+
83
+ References
84
+ ----------
85
+ Rubin, D. B. (1987). *Multiple Imputation for Nonresponse in
86
+ Surveys.* Wiley.
87
+ Barnard, J., & Rubin, D. B. (1999). Small-sample degrees of
88
+ freedom with multiple imputation. *Biometrika*, 86(4),
89
+ 948–955.
56
90
  """
91
+ if not (0.0 < conf_level < 1.0):
92
+ raise ValueError(
93
+ f"conf_level must lie in the open interval (0, 1); "
94
+ f"got {conf_level!r}."
95
+ )
57
96
  if len(models) < 2:
58
97
  raise ValueError(
59
98
  "pool requires at least two imputed-dataset fits "
@@ -77,6 +77,11 @@ def tbl_regression(
77
77
  Source dataframe — needed only when ``design=`` references
78
78
  columns that the fitted model didn't already see.
79
79
  """
80
+ if not (0.0 < conf_level < 1.0):
81
+ raise ValueError(
82
+ f"conf_level must lie in the open interval (0, 1); "
83
+ f"got {conf_level!r}."
84
+ )
80
85
  models = list(model) if isinstance(model, (list, tuple)) else [model]
81
86
  if not models:
82
87
  raise ValueError("tbl_regression requires at least one model.")
@@ -347,7 +352,13 @@ def _default_estimate_label(family_label: str, exponentiate: bool) -> str:
347
352
  if "cox" in fl or "phreg" in fl:
348
353
  return "HR"
349
354
  if "weibull" in fl or "lognormal" in fl or "loglogistic" in fl:
350
- return "HR" # AFT models report exp(coef) as a time ratio; HR is colloquial
355
+ # AFT family: exp(coef) is a TIME RATIO (also called Acceleration
356
+ # Factor), not a hazard ratio. TR > 1 means LONGER survival;
357
+ # HR > 1 means SHORTER survival — the two parameters point in
358
+ # opposite directions. Mislabelling AFT as "HR" is publication-
359
+ # critical because a reader will draw the wrong clinical
360
+ # conclusion.
361
+ return "TR"
351
362
  if "logit" in fl or "binomial" in fl or "probit" in fl or "logistic" in fl:
352
363
  return "OR"
353
364
  if "poisson" in fl or "negativebinomial" in fl:
@@ -77,10 +77,38 @@ def tbl_survival(
77
77
  "tbl_survival requires lifelines. Install with `pip install lifelines`."
78
78
  ) from e
79
79
 
80
+ if not (0.0 < conf_level < 1.0):
81
+ raise ValueError(
82
+ f"conf_level must lie in the open interval (0, 1); "
83
+ f"got {conf_level!r}."
84
+ )
80
85
  data = to_pandas(data)
81
86
  for col in (time, event):
82
87
  if col not in data.columns:
83
88
  raise KeyError(f"column {col!r} not in data")
89
+
90
+ # Validate time + event content. ``lifelines`` will silently treat
91
+ # negative survival times as zero and any nonzero event value as a
92
+ # death, so input mistakes (e.g. a "censor at last follow-up" column
93
+ # encoded as 0/1/9, or a follow-up time accidentally negated) can
94
+ # produce a misleading survival curve without complaint. Fail loud
95
+ # at the boundary instead.
96
+ time_num = pd.to_numeric(data[time], errors="coerce")
97
+ if (time_num < 0).any():
98
+ n_bad = int((time_num < 0).sum())
99
+ raise ValueError(
100
+ f"column {time!r} contains {n_bad} negative value(s); "
101
+ "survival times must be non-negative."
102
+ )
103
+ event_num = pd.to_numeric(data[event], errors="coerce").dropna()
104
+ bad_events = ~event_num.isin([0, 1])
105
+ if bool(bad_events.any()):
106
+ bad_vals = sorted(event_num[bad_events].unique().tolist())
107
+ raise ValueError(
108
+ f"column {event!r} must contain only 0/1 (or boolean) "
109
+ f"values; got unexpected values: {bad_vals!r}."
110
+ )
111
+
84
112
  if by is not None and by not in data.columns:
85
113
  raise KeyError(f"by column {by!r} not in data")
86
114
 
@@ -101,6 +101,29 @@ def _build_forest_figure(
101
101
  "`pip install matplotlib`."
102
102
  ) from e
103
103
 
104
+ # Multi-model `tbl_regression` tables emit one estimate / CI / p
105
+ # column triple per model and a spanning header per model. The
106
+ # current forest renderer plots a single series, so for multi-model
107
+ # tables it can only visualise one model. We pick the first model's
108
+ # columns (matching what gtsummary does by default when given a
109
+ # multi-model object), and emit a clear ``UserWarning`` so the user
110
+ # knows the other models were not drawn.
111
+ n_models = max(1, len(table.spanning_headers))
112
+ if n_models > 1:
113
+ import warnings as _w
114
+ first_label = table.spanning_headers[0].label
115
+ other_labels = [s.label for s in table.spanning_headers[1:]]
116
+ _w.warn(
117
+ f"with_forest_plot on a multi-model regression table plots "
118
+ f"only the first model ({first_label!r}); the remaining "
119
+ f"{len(other_labels)} model(s) {other_labels!r} are not "
120
+ f"visualised. Render one model at a time, or use "
121
+ f"`with_forest_plot(...)` on each single-model table "
122
+ f"separately.",
123
+ UserWarning,
124
+ stacklevel=2,
125
+ )
126
+
104
127
  points: list[tuple[str, float, float, float]] = []
105
128
  for r in table.rows:
106
129
  label = r.cells[0].text
@@ -24,8 +24,29 @@ import pandas as pd
24
24
  def cohen_d(a: pd.Series | np.ndarray, b: pd.Series | np.ndarray) -> float | None:
25
25
  """Cohen's d using the pooled standard deviation.
26
26
 
27
- ``d = (μ₁ − μ₂) / s_pool``, where the pooled SD weights the two
28
- samples by their degrees of freedom.
27
+ Parameters
28
+ ----------
29
+ a, b
30
+ Two independent samples (``pandas.Series`` or 1-D ``numpy``
31
+ array). Non-numeric entries are coerced; ``NaN`` rows are
32
+ dropped per array. Each sample must contain at least two
33
+ finite values.
34
+
35
+ Returns
36
+ -------
37
+ float or None
38
+ ``d = (μ_a − μ_b) / s_pool``, where the pooled SD weights the
39
+ two samples by their degrees of freedom:
40
+ ``s_pool = sqrt(((n_a − 1)·s_a² + (n_b − 1)·s_b²) / (n_a + n_b − 2))``.
41
+ Returns ``None`` if either sample has fewer than 2 finite
42
+ observations. Returns ``0.0`` if the pooled SD is zero and
43
+ the two means are identical; ``inf`` if the pooled SD is zero
44
+ but the means differ (degenerate constant-sample case).
45
+
46
+ References
47
+ ----------
48
+ Cohen, J. (1988). *Statistical Power Analysis for the Behavioral
49
+ Sciences* (2nd ed.). Lawrence Erlbaum.
29
50
  """
30
51
  a_arr = pd.to_numeric(pd.Series(a), errors="coerce").dropna().to_numpy(dtype=float)
31
52
  b_arr = pd.to_numeric(pd.Series(b), errors="coerce").dropna().to_numpy(dtype=float)