coeftable 0.1.0__tar.gz → 0.3.0__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 (48) hide show
  1. {coeftable-0.1.0 → coeftable-0.3.0}/.github/workflows/post-release.yml +1 -0
  2. coeftable-0.3.0/.github/workflows/publish.yml +68 -0
  3. coeftable-0.3.0/.github/workflows/release.yml +116 -0
  4. {coeftable-0.1.0 → coeftable-0.3.0}/.gitignore +2 -0
  5. coeftable-0.3.0/CHANGELOG.md +61 -0
  6. coeftable-0.3.0/PKG-INFO +405 -0
  7. coeftable-0.3.0/README.md +351 -0
  8. coeftable-0.3.0/docs/images/trend-example.png +0 -0
  9. {coeftable-0.1.0 → coeftable-0.3.0}/pyproject.toml +1 -0
  10. {coeftable-0.1.0 → coeftable-0.3.0}/src/coeftable/__init__.py +14 -1
  11. {coeftable-0.1.0 → coeftable-0.3.0}/src/coeftable/_version.py +2 -2
  12. coeftable-0.3.0/src/coeftable/collapsible.py +166 -0
  13. coeftable-0.3.0/src/coeftable/format.py +418 -0
  14. coeftable-0.3.0/src/coeftable/frame.py +280 -0
  15. coeftable-0.3.0/src/coeftable/grid.py +300 -0
  16. {coeftable-0.1.0 → coeftable-0.3.0}/src/coeftable/render.py +17 -4
  17. coeftable-0.3.0/src/coeftable/series.py +431 -0
  18. coeftable-0.3.0/src/coeftable/spec.py +1604 -0
  19. coeftable-0.3.0/src/coeftable/svg.py +1641 -0
  20. {coeftable-0.1.0 → coeftable-0.3.0}/src/coeftable/theme.py +83 -5
  21. coeftable-0.3.0/tests/test_collapsible.py +260 -0
  22. {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_format.py +52 -1
  23. {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_frame.py +82 -15
  24. {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_public_api.py +23 -0
  25. {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_render.py +57 -2
  26. coeftable-0.3.0/tests/test_series.py +353 -0
  27. coeftable-0.3.0/tests/test_sparkline.py +1404 -0
  28. {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_spec.py +37 -0
  29. coeftable-0.3.0/tests/test_svg.py +1904 -0
  30. {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_theme.py +42 -0
  31. {coeftable-0.1.0 → coeftable-0.3.0}/uv.lock +38 -0
  32. coeftable-0.1.0/.github/workflows/publish.yml +0 -38
  33. coeftable-0.1.0/PKG-INFO +0 -205
  34. coeftable-0.1.0/README.md +0 -152
  35. coeftable-0.1.0/src/coeftable/format.py +0 -237
  36. coeftable-0.1.0/src/coeftable/frame.py +0 -442
  37. coeftable-0.1.0/src/coeftable/spec.py +0 -437
  38. coeftable-0.1.0/src/coeftable/svg.py +0 -210
  39. coeftable-0.1.0/tests/test_svg.py +0 -90
  40. {coeftable-0.1.0 → coeftable-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  41. {coeftable-0.1.0 → coeftable-0.3.0}/.github/release.yml +0 -0
  42. {coeftable-0.1.0 → coeftable-0.3.0}/.github/workflows/ci.yml +0 -0
  43. {coeftable-0.1.0 → coeftable-0.3.0}/.pre-commit-config.yaml +0 -0
  44. {coeftable-0.1.0 → coeftable-0.3.0}/LICENSE +0 -0
  45. {coeftable-0.1.0 → coeftable-0.3.0}/Makefile +0 -0
  46. {coeftable-0.1.0 → coeftable-0.3.0}/docs/images/example.png +0 -0
  47. {coeftable-0.1.0 → coeftable-0.3.0}/noxfile.py +0 -0
  48. {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_package.py +0 -0
@@ -4,6 +4,7 @@ on:
4
4
  release:
5
5
  types: [published]
6
6
  workflow_dispatch:
7
+ workflow_call:
7
8
 
8
9
  jobs:
9
10
  changelog:
@@ -0,0 +1,68 @@
1
+ name: Publish library
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags:
7
+ - "v*"
8
+ workflow_call:
9
+ inputs:
10
+ ref:
11
+ description: "Git ref to build. Defaults to the triggering ref."
12
+ required: false
13
+ type: string
14
+ publish:
15
+ description: "Upload the built package to PyPI."
16
+ required: false
17
+ default: false
18
+ type: boolean
19
+ workflow_dispatch:
20
+
21
+ jobs:
22
+ build-package:
23
+ runs-on: ubuntu-latest
24
+ permissions:
25
+ attestations: write
26
+ contents: read
27
+ id-token: write
28
+ steps:
29
+ - uses: actions/checkout@v6
30
+ with:
31
+ # The version is derived from the tag by hatch-vcs, so the build
32
+ # must happen on the tag itself -- building the branch head would
33
+ # produce a development version instead of the release version.
34
+ ref: ${{ inputs.ref }}
35
+ fetch-depth: 0
36
+ persist-credentials: false
37
+ - uses: hynek/build-and-inspect-python-package@v2
38
+ with:
39
+ attest-build-provenance-github: true
40
+
41
+ publish:
42
+ runs-on: ubuntu-latest
43
+ # PyPI must have a trusted publisher registered for BOTH entry points,
44
+ # each with environment "publish": this workflow, for a manual run or a
45
+ # hand-pushed tag, and release.yml, for an automated release. Upload
46
+ # authentication matches on the reusable workflow, but the build
47
+ # provenance attestation carries the TOP-LEVEL workflow, so an automated
48
+ # release presents release.yml and is rejected with "does not match
49
+ # expected Trusted Publisher" unless that entry point is registered too.
50
+ #
51
+ # A tag pushed by a workflow using the default token does not raise a
52
+ # push event, so a release cut by automation asks for the upload
53
+ # explicitly. Any other run sitting on a tag -- a hand-pushed tag, or a
54
+ # manual run selecting one, which is how a stranded release gets
55
+ # published -- is also a release build. A run on a branch never is.
56
+ if: inputs.publish || startsWith(github.ref, 'refs/tags')
57
+ environment:
58
+ name: publish
59
+ url: https://pypi.org/p/coeftable
60
+ needs: build-package
61
+ permissions:
62
+ id-token: write
63
+ steps:
64
+ - uses: actions/download-artifact@v8
65
+ with:
66
+ name: Packages
67
+ path: dist
68
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,116 @@
1
+ name: Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ bump:
7
+ description: "Version bump"
8
+ required: true
9
+ type: choice
10
+ options:
11
+ - patch
12
+ - minor
13
+ - major
14
+ default: patch
15
+ version:
16
+ description: "Explicit version override (e.g. 1.2.3) -- takes precedence over bump"
17
+ required: false
18
+ type: string
19
+
20
+ permissions:
21
+ contents: write
22
+
23
+ concurrency:
24
+ group: release
25
+ cancel-in-progress: false
26
+
27
+ jobs:
28
+ tag:
29
+ runs-on: ubuntu-latest
30
+ outputs:
31
+ tag: ${{ steps.version.outputs.tag }}
32
+ steps:
33
+ - uses: actions/checkout@v6
34
+ with:
35
+ fetch-depth: 0
36
+
37
+ - name: Compute next version
38
+ id: version
39
+ env:
40
+ BUMP: ${{ inputs.bump }}
41
+ VERSION_OVERRIDE: ${{ inputs.version }}
42
+ run: |
43
+ set -euo pipefail
44
+
45
+ latest=$(git tag -l 'v*' --sort=-v:refname | sed 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)
46
+ latest="${latest:-0.0.0}"
47
+
48
+ if [ -n "$VERSION_OVERRIDE" ]; then
49
+ next="${VERSION_OVERRIDE#v}"
50
+ if ! [[ "$next" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
51
+ echo "::error::Invalid version '$next' -- must be MAJOR.MINOR.PATCH"
52
+ exit 1
53
+ fi
54
+
55
+ IFS='.' read -r next_major next_minor next_patch <<< "$next"
56
+ IFS='.' read -r latest_major latest_minor latest_patch <<< "$latest"
57
+ if [ "$((next_major * 1000000 + next_minor * 1000 + next_patch))" -le \
58
+ "$((latest_major * 1000000 + latest_minor * 1000 + latest_patch))" ]; then
59
+ echo "::error::Explicit version '$next' is not greater than the latest release 'v$latest'"
60
+ exit 1
61
+ fi
62
+ else
63
+ IFS='.' read -r major minor patch <<< "$latest"
64
+ case "$BUMP" in
65
+ major) next="$((major + 1)).0.0" ;;
66
+ minor) next="${major}.$((minor + 1)).0" ;;
67
+ patch) next="${major}.${minor}.$((patch + 1))" ;;
68
+ *) echo "::error::Unknown bump type '$BUMP'"; exit 1 ;;
69
+ esac
70
+ fi
71
+
72
+ tag="v${next}"
73
+ if git rev-parse "$tag" >/dev/null 2>&1; then
74
+ echo "::error::Tag $tag already exists"
75
+ exit 1
76
+ fi
77
+
78
+ echo "Latest: v$latest -> Next: $tag"
79
+ echo "tag=$tag" >> "$GITHUB_OUTPUT"
80
+
81
+ - name: Create and push tag
82
+ run: |
83
+ git config user.name "github-actions[bot]"
84
+ git config user.email "github-actions[bot]@users.noreply.github.com"
85
+ git tag -a "${{ steps.version.outputs.tag }}" -m "Release ${{ steps.version.outputs.tag }}"
86
+ git push origin "${{ steps.version.outputs.tag }}"
87
+
88
+ - name: Create GitHub release
89
+ env:
90
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
91
+ run: gh release create "${{ steps.version.outputs.tag }}" --generate-notes
92
+
93
+ publish:
94
+ # Called directly rather than left to the tag-push trigger on the publish
95
+ # workflow: the tag above is pushed with the workflow token, and GitHub
96
+ # does not raise workflow-triggering events for it.
97
+ needs: tag
98
+ uses: ./.github/workflows/publish.yml
99
+ permissions:
100
+ attestations: write
101
+ contents: read
102
+ id-token: write
103
+ with:
104
+ ref: ${{ needs.tag.outputs.tag }}
105
+ publish: true
106
+
107
+ changelog:
108
+ # Same reason as the publish job above: the release is created with the
109
+ # workflow token, so its published event never reaches the changelog
110
+ # workflow. Runs after the release exists, since the changelog is built
111
+ # from the published release notes.
112
+ needs: tag
113
+ uses: ./.github/workflows/post-release.yml
114
+ permissions:
115
+ contents: write
116
+ pull-requests: write
@@ -16,3 +16,5 @@ src/coeftable/_version.py
16
16
  .kata.local.toml
17
17
  # roborev snapshots
18
18
  /.roborev/
19
+ # internal planning docs (not shipped, not for the public repo)
20
+ docs/superpowers/
@@ -0,0 +1,61 @@
1
+ <a id="v0.2.1"></a>
2
+ # [v0.2.1](https://github.com/kylejcaron/coeftable/releases/tag/v0.2.1) - 2026-08-05
3
+
4
+ <!-- Release notes generated using configuration in .github/release.yml at v0.2.1 -->
5
+
6
+ ## What's Changed
7
+ ### Maintenance
8
+ * Update changelog for v0.2.0 by [@github-actions](https://github.com/github-actions)[bot] in [#11](https://github.com/kylejcaron/coeftable/pull/11)
9
+ * Publish releases to PyPI automatically by [@kylejcaron](https://github.com/kylejcaron) in [#12](https://github.com/kylejcaron/coeftable/pull/12)
10
+
11
+ ## New Contributors
12
+ * [@github-actions](https://github.com/github-actions)[bot] made their first contribution in [#11](https://github.com/kylejcaron/coeftable/pull/11)
13
+
14
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.2.0...v0.2.1
15
+
16
+ [Changes][v0.2.1]
17
+
18
+
19
+ <a id="v0.2.0"></a>
20
+ # [v0.2.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.2.0) - 2026-08-04
21
+
22
+ <!-- Release notes generated using configuration in .github/release.yml at v0.2.0 -->
23
+
24
+ ## What's Changed
25
+ ### Maintenance
26
+ * ci: add workflow_dispatch release workflow with semver bump by [@kylejcaron](https://github.com/kylejcaron) in [#7](https://github.com/kylejcaron/coeftable/pull/7)
27
+ * Sparkline column: inline line plots with uncertainty by [@kylejcaron](https://github.com/kylejcaron) in [#10](https://github.com/kylejcaron/coeftable/pull/10)
28
+
29
+
30
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.1.0...v0.2.0
31
+
32
+ [Changes][v0.2.0]
33
+
34
+
35
+ <a id="v0.1.0"></a>
36
+ # [v0.1.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.1.0) - 2026-07-28
37
+
38
+ <!-- Release notes generated using configuration in .github/release.yml at main -->
39
+
40
+ ## What's Changed
41
+ ### Maintenance
42
+ * chore: install pre-commit hooks during setup by [@kylejcaron](https://github.com/kylejcaron) in [#1](https://github.com/kylejcaron/coeftable/pull/1)
43
+ * ci: drop redundant lint and typecheck jobs by [@kylejcaron](https://github.com/kylejcaron) in [#2](https://github.com/kylejcaron/coeftable/pull/2)
44
+ * Theming by [@kylejcaron](https://github.com/kylejcaron) in [#3](https://github.com/kylejcaron/coeftable/pull/3)
45
+ * docs patch: restore Parameters section header on Forest docstring by [@kylejcaron](https://github.com/kylejcaron) in [#4](https://github.com/kylejcaron/coeftable/pull/4)
46
+ * chore: complete package metadata for PyPI publishing by [@kylejcaron](https://github.com/kylejcaron) in [#5](https://github.com/kylejcaron/coeftable/pull/5)
47
+ * docs: add hero image, fix stale theming section in README by [@kylejcaron](https://github.com/kylejcaron) in [#6](https://github.com/kylejcaron/coeftable/pull/6)
48
+
49
+ ## New Contributors
50
+ * [@kylejcaron](https://github.com/kylejcaron) made their first contribution in [#1](https://github.com/kylejcaron/coeftable/pull/1)
51
+
52
+ **Full Changelog**: https://github.com/kylejcaron/coeftable/commits/v0.1.0
53
+
54
+ [Changes][v0.1.0]
55
+
56
+
57
+ [v0.2.1]: https://github.com/kylejcaron/coeftable/compare/v0.2.0...v0.2.1
58
+ [v0.2.0]: https://github.com/kylejcaron/coeftable/compare/v0.1.0...v0.2.0
59
+ [v0.1.0]: https://github.com/kylejcaron/coeftable/tree/v0.1.0
60
+
61
+ <!-- Generated by https://github.com/rhysd/changelog-from-release v3.9.1 -->
@@ -0,0 +1,405 @@
1
+ Metadata-Version: 2.4
2
+ Name: coeftable
3
+ Version: 0.3.0
4
+ Summary: Publication-quality summary tables for estimates with uncertainty.
5
+ Project-URL: Homepage, https://github.com/kylejcaron/coeftable
6
+ Project-URL: Repository, https://github.com/kylejcaron/coeftable
7
+ Project-URL: Issues, https://github.com/kylejcaron/coeftable/issues
8
+ Author-email: Kyle Caron <kyle.j.caron@gmail.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Kyle Caron
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: confidence-interval,forest-plot,great-tables,statistics,tables
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: Intended Audience :: Science/Research
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Programming Language :: Python :: 3.13
38
+ Classifier: Programming Language :: Python :: 3.14
39
+ Classifier: Topic :: Scientific/Engineering
40
+ Requires-Python: >=3.12
41
+ Requires-Dist: great-tables>=0.22
42
+ Requires-Dist: narwhals>=2.24
43
+ Provides-Extra: dev
44
+ Requires-Dist: nox>=2025.5; extra == 'dev'
45
+ Requires-Dist: pandas>=2.2; extra == 'dev'
46
+ Requires-Dist: polars>=1.0; extra == 'dev'
47
+ Requires-Dist: prek>=0.4.5; extra == 'dev'
48
+ Requires-Dist: pyarrow>=25.0.0; extra == 'dev'
49
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
50
+ Requires-Dist: pytest>=8.0; extra == 'dev'
51
+ Requires-Dist: ruff>=0.15; extra == 'dev'
52
+ Requires-Dist: ty>=0.0.49; extra == 'dev'
53
+ Description-Content-Type: text/markdown
54
+
55
+ # coeftable
56
+
57
+ Lightweight, report-ready summary tables for estimates with uncertainty. Renders
58
+ inline forest plots, builds on great_tables HTML output, and works with pandas,
59
+ polars, or pyarrow frames.
60
+
61
+ ![Rendered experiment results table with grouped sections, nested variants, and an inline forest plot](docs/images/example.png)
62
+
63
+ ## Installation
64
+
65
+ ```bash
66
+ uv add coeftable
67
+ ```
68
+
69
+ ## Quick start
70
+
71
+ The one-line form declares a table with a single estimate column:
72
+
73
+ ```python
74
+ import polars as pl
75
+ import coeftable as ct
76
+
77
+ df = pl.DataFrame(
78
+ {
79
+ "metric": ["Revenue", "Latency"],
80
+ "est": [3.4, 0.5],
81
+ "lb": [1.2, -1.0],
82
+ "ub": [5.7, 2.0],
83
+ }
84
+ )
85
+
86
+ ct.CoefTable(df, rows="metric", estimate="est", ci=("lb", "ub"))
87
+ ```
88
+
89
+ A `CoefTable` renders itself in marimo, Jupyter, and any other `_repr_html_`-aware viewer —
90
+ leave it as the last expression in a cell, no extra call needed. Outside a notebook,
91
+ `table.as_raw_html()` is the HTML string entry point. `.gt()` remains the escape hatch to the
92
+ underlying [great_tables](https://posit-dev.github.io/great-tables/) object itself:
93
+ `table.gt().save("t.png")` for an image, `table.gt().tab_options(...)` to keep styling with
94
+ great_tables' own API.
95
+
96
+ ## Data shape
97
+
98
+ coeftable expects a dataframe where every row is a single comparison. The
99
+ resolution logic maps pairs of upper / lower bound columns to each estimate, so
100
+ your data should be **wide in triples** — one point-estimate column and (when
101
+ applicable) its lower and upper bound columns — rather than in long format with
102
+ a `parameter` column.
103
+
104
+ **Dimensions:**
105
+ - `rows` — the label for each row in the table (e.g. a metric name).
106
+ - `nest` — an optional secondary label stacked below each row.
107
+ - `groups` — an optional column whose values produce section headers. Pass
108
+ `collapsible_groups=True` to make those sections collapsible in the rendered
109
+ HTML — a pure CSS toggle (relies on `:has()`, Baseline since late 2023; on an
110
+ older browser without it the toggle no-ops and sections stay expanded), no
111
+ JavaScript, sections start expanded. Applies to `as_raw_html()`/`_repr_html_`;
112
+ `.gt()` is unaffected.
113
+ - `split_columns` — an optional column whose values produce repeated column
114
+ groups side by side, useful for comparing methods.
115
+
116
+ **Series columns bend this rule.** A point estimate is one number (plus
117
+ bounds), so a triple of scalar columns holds it. A `.sparkline(...)` series
118
+ is N points, not one -- most naturally via the companion-frame door, a
119
+ separate long frame with one row per point, joined by the table's row/nest/
120
+ split keys. Or, when the series is already collapsed onto its row, its
121
+ `value` / `ci` columns can instead hold a *list* per row directly. Either
122
+ way a row of the table is still one row; the series column just carries
123
+ more data per row than an estimate column does. See
124
+ [Trend over time](#trend-over-time) for both shapes.
125
+
126
+ ## Experiment table
127
+
128
+ Build a complete experiment results table with multiple estimates, a forest
129
+ plot column, grouped row sections, nested variants, and direction hints:
130
+
131
+ ```python
132
+ import polars as pl
133
+ import coeftable as ct
134
+
135
+ experiment = pl.DataFrame(
136
+ {
137
+ "area": ["Core", "Core", "Ops", "Ops"],
138
+ "metric": ["Revenue", "Revenue", "Latency", "Latency"],
139
+ "variant": ["B", "C", "B", "C"],
140
+ "att": [12400.0, -3100.0, 40.0, 120.0],
141
+ "att_lb": [4200.0, -9800.0, -80.0, 45.0],
142
+ "att_ub": [20600.0, 3600.0, 160.0, 195.0],
143
+ "rel": [3.4, -1.2, 0.5, 2.0],
144
+ "rel_lb": [1.2, -4.0, -1.0, 0.8],
145
+ "rel_ub": [5.7, 1.6, 2.0, 3.2],
146
+ }
147
+ )
148
+
149
+ (
150
+ ct.CoefTable(experiment, rows="metric", nest="variant", groups="area")
151
+ .estimate("Lift Amount", "att", ci=("att_lb", "att_ub"), fmt=ct.Number(compact=True))
152
+ .estimate("Lift %", "rel", ci=("rel_lb", "rel_ub"), fmt=ct.Percent(signed=True))
153
+ .forest("Lift Plot", of="Lift %", ref=0.0, symmetric=True)
154
+ .header("Experiment Results", "Example Experiment")
155
+ .with_direction({"Latency": "lower_is_better"})
156
+ )
157
+ ```
158
+
159
+ ## Comparing methods
160
+
161
+ Use `split_columns` to compare multiple methods side by side. Each value in the
162
+ split column produces its own set of estimate / forest columns:
163
+
164
+ ```python
165
+ import polars as pl
166
+ import coeftable as ct
167
+
168
+ methods = pl.DataFrame(
169
+ {
170
+ "metric": ["Revenue", "Revenue", "Latency", "Latency"],
171
+ "method": ["A", "B", "A", "B"],
172
+ "est": [3.4, 3.1, 0.5, 0.6],
173
+ "lb": [1.2, 1.0, -1.0, -0.8],
174
+ "ub": [5.7, 5.2, 2.0, 2.1],
175
+ }
176
+ )
177
+
178
+ (
179
+ ct.CoefTable(
180
+ methods, rows="metric", split_columns="method", estimate="est", ci=("lb", "ub")
181
+ ).header("Cohort Revenue by Method")
182
+ )
183
+ ```
184
+
185
+ ## Theming
186
+
187
+ Four built-in themes are available from `coeftable.theme`:
188
+
189
+ ```python
190
+ from coeftable.theme import BLUE, COLORBLIND, DEFAULT, MONO, TEXTUAL
191
+
192
+ DEFAULT # Alias for TEXTUAL -- what CoefTable uses if you don't set a theme
193
+ TEXTUAL # Minimal, publication-style: muted colours, light chrome
194
+ BLUE # The original blue-grey palette
195
+ COLORBLIND # Colourblind-safe palette
196
+ MONO # Grayscale for mono journals
197
+ ```
198
+
199
+ Apply one with `.with_theme(...)`:
200
+
201
+ ```python
202
+ table.with_theme(BLUE)
203
+ ```
204
+
205
+ Customise a theme with `dataclasses.replace`:
206
+
207
+ ```python
208
+ from dataclasses import replace
209
+
210
+ my_theme = replace(BLUE, favorable="#0072B2")
211
+ ```
212
+
213
+ Use `with_direction` to mark rows where lower values are favourable (reusing
214
+ the `df` frame from [Quick start](#quick-start)):
215
+
216
+ ```python
217
+ table = ct.CoefTable(df, rows="metric", estimate="est", ci=("lb", "ub")).with_direction(
218
+ {"Latency": "lower_is_better"}
219
+ )
220
+ ```
221
+
222
+ ## Trend over time
223
+
224
+ ![Rendered experiment results table with a 30-day trend column showing favorable, unfavorable, and inconclusive series with narrowing uncertainty bands](docs/images/trend-example.png)
225
+
226
+ Add a `.sparkline(...)` column to plot a metric's trajectory next to its
227
+ point estimate: an inline SVG line with a shaded credible interval and a
228
+ dashed reference line (pass `show_endpoint=True` to also label the last
229
+ value). Below, `ref=0.0` draws the reference line that Latency's series
230
+ crosses as its credible interval narrows over three weeks of data:
231
+
232
+ There are two front doors for the series data, the same list-columns vs.
233
+ companion-frame choice used elsewhere in coeftable:
234
+
235
+ **A long companion frame** — the shape most real series data already
236
+ arrives in: a SQL export, a dbt model, an experimentation platform's daily
237
+ metrics table. Pass `data=` a separate frame with one row per point, and
238
+ `value` / `ci` / `x` name *scalar* columns on it. coeftable groups the
239
+ companion frame by the table's `rows` (+ `nest`, + `split_columns`) keys
240
+ and collapses each group into a series internally:
241
+
242
+ ```python
243
+ import datetime as dt
244
+ import pandas as pd
245
+ import polars as pl
246
+ import coeftable as ct
247
+
248
+ dates = [dt.date(2024, 1, 1), dt.date(2024, 1, 8), dt.date(2024, 1, 15)]
249
+
250
+ trend = pl.DataFrame(
251
+ {
252
+ "metric": ["Revenue", "Latency"],
253
+ "lift": [3.4, 0.5],
254
+ "lift_lb": [1.2, -1.0],
255
+ "lift_ub": [5.7, 2.0],
256
+ }
257
+ )
258
+
259
+ history = pd.DataFrame(
260
+ {
261
+ "metric": ["Revenue", "Revenue", "Revenue", "Latency", "Latency", "Latency"],
262
+ "date": dates + dates,
263
+ "lift": [1.5, 2.4, 3.4, -1.0, 0.2, 1.5],
264
+ "lift_lb": [0.3, 1.4, 2.6, -2.5, -0.6, 1.0],
265
+ "lift_ub": [2.7, 3.4, 4.2, 0.5, 1.0, 2.0],
266
+ }
267
+ )
268
+
269
+ (
270
+ ct.CoefTable(trend, rows="metric")
271
+ .estimate("Lift %", "lift", ci=("lift_lb", "lift_ub"), fmt=ct.Percent(signed=True))
272
+ .sparkline(
273
+ "Trend",
274
+ value="lift",
275
+ ci=("lift_lb", "lift_ub"),
276
+ x="date",
277
+ data=history,
278
+ ref=0.0,
279
+ axis_fmt=ct.DateAxis(),
280
+ )
281
+ )
282
+ ```
283
+
284
+ **List columns on the main frame** — if the series is already collapsed
285
+ onto its row (e.g. from a prior `.group_by(...).agg(...)`, or a source that
286
+ natively stores arrays), `value` / `ci` / `x` can instead name columns
287
+ whose cells each hold one list of points per row:
288
+
289
+ ```python
290
+ import datetime as dt
291
+ import polars as pl
292
+ import coeftable as ct
293
+
294
+ dates = [dt.date(2024, 1, 1), dt.date(2024, 1, 8), dt.date(2024, 1, 15)]
295
+
296
+ trend = pl.DataFrame(
297
+ {
298
+ "metric": ["Revenue", "Latency"],
299
+ "lift": [3.4, 0.5],
300
+ "lift_lb": [1.2, -1.0],
301
+ "lift_ub": [5.7, 2.0],
302
+ "history": [
303
+ [1.5, 2.4, 3.4],
304
+ [-1.0, 0.2, 1.5],
305
+ ],
306
+ "history_lb": [
307
+ [0.3, 1.4, 2.6],
308
+ [-2.5, -0.6, 1.0],
309
+ ],
310
+ "history_ub": [
311
+ [2.7, 3.4, 4.2],
312
+ [0.5, 1.0, 2.0],
313
+ ],
314
+ "date": [dates, dates],
315
+ }
316
+ )
317
+
318
+ (
319
+ ct.CoefTable(trend, rows="metric")
320
+ .estimate("Lift %", "lift", ci=("lift_lb", "lift_ub"), fmt=ct.Percent(signed=True))
321
+ .sparkline(
322
+ "Trend",
323
+ value="history",
324
+ ci=("history_lb", "history_ub"),
325
+ x="date",
326
+ ref=0.0,
327
+ axis_fmt=ct.DateAxis(),
328
+ )
329
+ )
330
+ ```
331
+
332
+ Both render the same column. Reach for the companion frame first — it
333
+ matches how most series data actually arrives, one row per observation.
334
+ Reach for list columns only when the series is already collapsed onto its
335
+ row.
336
+
337
+ Since `x` is always shared table-wide (dates must line up across rows), a
338
+ series with fewer points than its neighbours visibly occupies only part of
339
+ its cell's width rather than stretching to fill it — this is intentional,
340
+ not a bug: `x` position reflects where a point falls in the shared domain,
341
+ never the row's own extent.
342
+
343
+ Want a plain trend line with no uncertainty band — no `ci`, no ribbon?
344
+ great_tables' own `.gt().fmt_nanoplot(...)` covers that directly.
345
+ `.sparkline(...)` exists specifically for the estimate-with-interval case.
346
+
347
+ **Shaping the y-axis.** Each row's domain fits tightly to its own data by
348
+ default (`scale="row"`, `autoscale="tight"`). Four ways to change that,
349
+ shown together against the same noisy series:
350
+
351
+ ```python
352
+ import polars as pl
353
+ import coeftable as ct
354
+
355
+ trend = pl.DataFrame(
356
+ {
357
+ "metric": ["Revenue"],
358
+ "lift": [[1.0, 1.05, 0.95, 1.02, 0.98, 300.0]],
359
+ }
360
+ )
361
+
362
+ (
363
+ ct.CoefTable(trend, rows="metric")
364
+ # Default: fits tightly to this row's own min/max. A single outlier
365
+ # like the 300.0 here dominates and flattens the rest of the series.
366
+ .sparkline("Tight (default)", value="lift", ref=1.0)
367
+ # autoscale="robust" fits an IQR/Tukey fence instead of raw min/max,
368
+ # so the outlier doesn't flatten the rest. It still draws -- clipped
369
+ # to the domain edge and flagged with a clip-cap marker, never hidden.
370
+ .sparkline("Robust", value="lift", ref=1.0, autoscale="robust")
371
+ # max_ylim=N narrows whatever domain scale/autoscale would have
372
+ # produced -- clamping to `ref +/- N`, only if the natural domain
373
+ # would have exceeded that ceiling. Composes with autoscale.
374
+ .sparkline("Ceiling", value="lift", ref=1.0, max_ylim=0.5)
375
+ # ylim=(lo, hi) is an absolute override, replacing scale/autoscale/
376
+ # max_ylim entirely.
377
+ .sparkline("Override", value="lift", ref=1.0, ylim=(0.9, 1.1))
378
+ )
379
+ ```
380
+
381
+ **No reference, or a hidden one.** `ref` also drives the dashed line and
382
+ colour resolution; two ways to opt out, for data with no meaningful
383
+ zero (revenue, durations, absolute counts):
384
+
385
+ ```python
386
+ absolute = pl.DataFrame(
387
+ {
388
+ "metric": ["Revenue", "Latency"],
389
+ "value": [[282.3, 300.1, 320.0], [900.0, 910.0, 920.0]],
390
+ }
391
+ )
392
+
393
+ (
394
+ ct.CoefTable(absolute, rows="metric")
395
+ # ref=None: no reference at all. No dashed line, no forced domain
396
+ # inclusion of 0, and every cell colours neutral -- "favorable" has
397
+ # no meaning without something to compare against.
398
+ .sparkline("No reference", value="value", ref=None)
399
+ # ref=0.0, show_ref=False: the reference is real and still drives
400
+ # colour (favorable/unfavorable against 0), it just isn't drawn or
401
+ # forced into the domain. Opt in deliberately: a mark can then claim
402
+ # "above the reference" while the reference sits off-canvas.
403
+ .sparkline("Hidden reference", value="value", ref=0.0, show_ref=False)
404
+ )
405
+ ```