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.
- {coeftable-0.1.0 → coeftable-0.3.0}/.github/workflows/post-release.yml +1 -0
- coeftable-0.3.0/.github/workflows/publish.yml +68 -0
- coeftable-0.3.0/.github/workflows/release.yml +116 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/.gitignore +2 -0
- coeftable-0.3.0/CHANGELOG.md +61 -0
- coeftable-0.3.0/PKG-INFO +405 -0
- coeftable-0.3.0/README.md +351 -0
- coeftable-0.3.0/docs/images/trend-example.png +0 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/pyproject.toml +1 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/src/coeftable/__init__.py +14 -1
- {coeftable-0.1.0 → coeftable-0.3.0}/src/coeftable/_version.py +2 -2
- coeftable-0.3.0/src/coeftable/collapsible.py +166 -0
- coeftable-0.3.0/src/coeftable/format.py +418 -0
- coeftable-0.3.0/src/coeftable/frame.py +280 -0
- coeftable-0.3.0/src/coeftable/grid.py +300 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/src/coeftable/render.py +17 -4
- coeftable-0.3.0/src/coeftable/series.py +431 -0
- coeftable-0.3.0/src/coeftable/spec.py +1604 -0
- coeftable-0.3.0/src/coeftable/svg.py +1641 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/src/coeftable/theme.py +83 -5
- coeftable-0.3.0/tests/test_collapsible.py +260 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_format.py +52 -1
- {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_frame.py +82 -15
- {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_public_api.py +23 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_render.py +57 -2
- coeftable-0.3.0/tests/test_series.py +353 -0
- coeftable-0.3.0/tests/test_sparkline.py +1404 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_spec.py +37 -0
- coeftable-0.3.0/tests/test_svg.py +1904 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_theme.py +42 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/uv.lock +38 -0
- coeftable-0.1.0/.github/workflows/publish.yml +0 -38
- coeftable-0.1.0/PKG-INFO +0 -205
- coeftable-0.1.0/README.md +0 -152
- coeftable-0.1.0/src/coeftable/format.py +0 -237
- coeftable-0.1.0/src/coeftable/frame.py +0 -442
- coeftable-0.1.0/src/coeftable/spec.py +0 -437
- coeftable-0.1.0/src/coeftable/svg.py +0 -210
- coeftable-0.1.0/tests/test_svg.py +0 -90
- {coeftable-0.1.0 → coeftable-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/.github/release.yml +0 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/.github/workflows/ci.yml +0 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/.pre-commit-config.yaml +0 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/LICENSE +0 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/Makefile +0 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/docs/images/example.png +0 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/noxfile.py +0 -0
- {coeftable-0.1.0 → coeftable-0.3.0}/tests/test_package.py +0 -0
|
@@ -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
|
|
@@ -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 -->
|
coeftable-0.3.0/PKG-INFO
ADDED
|
@@ -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
|
+

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

|
|
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
|
+
```
|