Skip to main content

coeftable

Lightweight, report-ready summary tables for estimates with uncertainty. Renders inline forest plots, builds on great_tables HTML output, and works with pandas, polars, or pyarrow frames.

Rendered experiment results table with grouped sections, nested variants, and an inline forest plot

Installation

uv add coeftable

Quick start

The one-line form declares a table with a single estimate column:

import polars as pl
import coeftable as ct

df = pl.DataFrame(
    {
        "metric": ["Revenue", "Latency"],
        "est": [3.4, 0.5],
        "lb": [1.2, -1.0],
        "ub": [5.7, 2.0],
    }
)

ct.CoefTable(df, rows="metric", estimate="est", ci=("lb", "ub"))

A CoefTable renders itself in marimo, Jupyter, and any other _repr_html_-aware viewer — leave it as the last expression in a cell, no extra call needed. Outside a notebook, table.as_raw_html() is the HTML string entry point. .gt() remains the escape hatch to the underlying great_tables object itself: table.gt().save("t.png") for an image, table.gt().tab_options(...) to keep styling with great_tables' own API.

Data shape

coeftable expects a dataframe where every row is a single comparison. The resolution logic maps pairs of upper / lower bound columns to each estimate, so your data should be wide in triples — one point-estimate column and (when applicable) its lower and upper bound columns — rather than in long format with a parameter column.

Dimensions:

  • rows — the label for each row in the table (e.g. a metric name).
  • nest — an optional secondary label stacked below each row.
  • groups — an optional column whose values produce section headers. A row label may appear under more than one group, in which case it renders once per section — so the same set of metrics can be reported per region without inventing a nest column to tell them apart. Pass collapsible_groups=True to make those sections collapsible in the rendered HTML — a pure CSS toggle (relies on :has(), Baseline since late 2023; on an older browser without it the toggle no-ops and sections stay expanded), no JavaScript, sections start expanded. Applies to as_raw_html()/_repr_html_; .gt() is unaffected.
  • split_columns — an optional column whose values produce repeated column groups side by side, useful for comparing methods.

Series columns bend this rule. A point estimate is one number (plus bounds), so a triple of scalar columns holds it. A .sparkline(...) series is N points, not one -- most naturally via the companion-frame door, a separate long frame with one row per point, joined by the table's row/nest/ split keys. Or, when the series is already collapsed onto its row, its value / ci columns can instead hold a list per row directly. Either way a row of the table is still one row; the series column just carries more data per row than an estimate column does. See Trend over time for both shapes.

Experiment table

Build a complete experiment results table with multiple estimates, a forest plot column, grouped row sections, nested variants, and direction hints:

import polars as pl
import coeftable as ct

experiment = pl.DataFrame(
    {
        "area": ["Core", "Core", "Ops", "Ops"],
        "metric": ["Revenue", "Revenue", "Latency", "Latency"],
        "variant": ["B", "C", "B", "C"],
        "att": [12400.0, -3100.0, 40.0, 120.0],
        "att_lb": [4200.0, -9800.0, -80.0, 45.0],
        "att_ub": [20600.0, 3600.0, 160.0, 195.0],
        "rel": [3.4, -1.2, 0.5, 2.0],
        "rel_lb": [1.2, -4.0, -1.0, 0.8],
        "rel_ub": [5.7, 1.6, 2.0, 3.2],
    }
)

(
    ct.CoefTable(experiment, rows="metric", nest="variant", groups="area")
    .estimate("Lift Amount", "att", ci=("att_lb", "att_ub"), fmt=ct.Number(compact=True))
    .estimate("Lift %", "rel", ci=("rel_lb", "rel_ub"), fmt=ct.Percent(signed=True))
    .forest("Lift Plot", of="Lift %", ref=0.0, symmetric=True)
    .header("Experiment Results", "Example Experiment")
    .with_direction({"Latency": "lower_is_better"})
)

Comparing methods

Use split_columns to compare multiple methods side by side. Each value in the split column produces its own set of estimate / forest columns:

import polars as pl
import coeftable as ct

methods = pl.DataFrame(
    {
        "metric": ["Revenue", "Revenue", "Latency", "Latency"],
        "method": ["A", "B", "A", "B"],
        "est": [3.4, 3.1, 0.5, 0.6],
        "lb": [1.2, 1.0, -1.0, -0.8],
        "ub": [5.7, 5.2, 2.0, 2.1],
    }
)

(
    ct.CoefTable(
        methods, rows="metric", split_columns="method", estimate="est", ci=("lb", "ub")
    ).header("Cohort Revenue by Method")
)

Repeating metrics across sections

A row label may appear under more than one groups value. The same metrics can therefore be reported per region, each section repeating the full set:

import polars as pl
import coeftable as ct

regions = pl.DataFrame(
    {
        "metric": ["Revenue", "Signups", "Revenue", "Signups"],
        "region": ["US", "US", "EU", "EU"],
        "est": [1.2, 0.4, 2.1, 0.9],
        "lb": [0.8, 0.1, 1.5, 0.5],
        "ub": [1.6, 0.7, 2.7, 1.3],
    }
)

(
    ct.CoefTable(
        regions, rows="metric", groups="region", collapsible_groups=True
    )
    .estimate("Effect", "est", ci=("lb", "ub"))
    .forest("Effect Plot", of="Effect", ref=0.0, symmetric=True)
)

Theming

Four built-in themes are available from coeftable.theme:

from coeftable.theme import BLUE, COLORBLIND, DEFAULT, MONO, TEXTUAL

DEFAULT  # Alias for TEXTUAL -- what CoefTable uses if you don't set a theme
TEXTUAL  # Minimal, publication-style: muted colours, light chrome
BLUE  # The original blue-grey palette
COLORBLIND  # Colourblind-safe palette
MONO  # Grayscale for mono journals

Apply one with .with_theme(...):

table.with_theme(BLUE)

Customise a theme with dataclasses.replace:

from dataclasses import replace

my_theme = replace(BLUE, favorable="#0072B2")

Use with_direction to mark rows where lower values are favourable (reusing the df frame from Quick start):

table = ct.CoefTable(df, rows="metric", estimate="est", ci=("lb", "ub")).with_direction(
    {"Latency": "lower_is_better"}
)

Trend over time

Rendered experiment results table with a 30-day trend column showing favorable, unfavorable, and inconclusive series with narrowing uncertainty bands

Add a .sparkline(...) column to plot a metric's trajectory next to its point estimate: an inline SVG line with a shaded credible interval and a dashed reference line (pass show_endpoint=True to also label the last value). Below, ref=0.0 draws the reference line that Latency's series crosses as its credible interval narrows over three weeks of data:

There are two front doors for the series data, the same list-columns vs. companion-frame choice used elsewhere in coeftable:

A long companion frame — the shape most real series data already arrives in: a SQL export, a dbt model, an experimentation platform's daily metrics table. Pass data= a separate frame with one row per point, and value / ci / x name scalar columns on it. coeftable groups the companion frame by the table's rows (+ nest, + groups, + split_columns) keys and collapses each group into a series internally. groups participates only when data actually carries that column, so a companion frame keyed on the row alone stays valid. The one case that needs it is a row label appearing under more than one group: without the group column in data those rows are indistinguishable, so coeftable reports it rather than serving every section the same merged series.

import datetime as dt
import pandas as pd
import polars as pl
import coeftable as ct

dates = [dt.date(2024, 1, 1), dt.date(2024, 1, 8), dt.date(2024, 1, 15)]

trend = pl.DataFrame(
    {
        "metric": ["Revenue", "Latency"],
        "lift": [3.4, 0.5],
        "lift_lb": [1.2, -1.0],
        "lift_ub": [5.7, 2.0],
    }
)

history = pd.DataFrame(
    {
        "metric": ["Revenue", "Revenue", "Revenue", "Latency", "Latency", "Latency"],
        "date": dates + dates,
        "lift": [1.5, 2.4, 3.4, -1.0, 0.2, 1.5],
        "lift_lb": [0.3, 1.4, 2.6, -2.5, -0.6, 1.0],
        "lift_ub": [2.7, 3.4, 4.2, 0.5, 1.0, 2.0],
    }
)

(
    ct.CoefTable(trend, rows="metric")
    .estimate("Lift %", "lift", ci=("lift_lb", "lift_ub"), fmt=ct.Percent(signed=True))
    .sparkline(
        "Trend",
        value="lift",
        ci=("lift_lb", "lift_ub"),
        x="date",
        data=history,
        ref=0.0,
        axis_fmt=ct.DateAxis(),
    )
)

List columns on the main frame — if the series is already collapsed onto its row (e.g. from a prior .group_by(...).agg(...), or a source that natively stores arrays), value / ci / x can instead name columns whose cells each hold one list of points per row:

import datetime as dt
import polars as pl
import coeftable as ct

dates = [dt.date(2024, 1, 1), dt.date(2024, 1, 8), dt.date(2024, 1, 15)]

trend = pl.DataFrame(
    {
        "metric": ["Revenue", "Latency"],
        "lift": [3.4, 0.5],
        "lift_lb": [1.2, -1.0],
        "lift_ub": [5.7, 2.0],
        "history": [
            [1.5, 2.4, 3.4],
            [-1.0, 0.2, 1.5],
        ],
        "history_lb": [
            [0.3, 1.4, 2.6],
            [-2.5, -0.6, 1.0],
        ],
        "history_ub": [
            [2.7, 3.4, 4.2],
            [0.5, 1.0, 2.0],
        ],
        "date": [dates, dates],
    }
)

(
    ct.CoefTable(trend, rows="metric")
    .estimate("Lift %", "lift", ci=("lift_lb", "lift_ub"), fmt=ct.Percent(signed=True))
    .sparkline(
        "Trend",
        value="history",
        ci=("history_lb", "history_ub"),
        x="date",
        ref=0.0,
        axis_fmt=ct.DateAxis(),
    )
)

Both render the same column. Reach for the companion frame first — it matches how most series data actually arrives, one row per observation. Reach for list columns only when the series is already collapsed onto its row.

Since x is always shared table-wide (dates must line up across rows), a series with fewer points than its neighbours visibly occupies only part of its cell's width rather than stretching to fill it — this is intentional, not a bug: x position reflects where a point falls in the shared domain, never the row's own extent.

Want a plain trend line with no uncertainty band — no ci, no ribbon? great_tables' own .gt().fmt_nanoplot(...) covers that directly. .sparkline(...) exists specifically for the estimate-with-interval case.

Shaping the y-axis. Each row's domain fits tightly to its own data by default (scale="row", autoscale="tight"). Four ways to change that, shown together against the same noisy series:

import polars as pl
import coeftable as ct

trend = pl.DataFrame(
    {
        "metric": ["Revenue"],
        "lift": [[1.0, 1.05, 0.95, 1.02, 0.98, 300.0]],
    }
)

(
    ct.CoefTable(trend, rows="metric")
    # Default: fits tightly to this row's own min/max. A single outlier
    # like the 300.0 here dominates and flattens the rest of the series.
    .sparkline("Tight (default)", value="lift", ref=1.0)
    # autoscale="robust" fits an IQR/Tukey fence instead of raw min/max,
    # so the outlier doesn't flatten the rest. It still draws -- clipped
    # to the domain edge and flagged with a clip-cap marker, never hidden.
    .sparkline("Robust", value="lift", ref=1.0, autoscale="robust")
    # max_ylim=N narrows whatever domain scale/autoscale would have
    # produced -- clamping to `ref +/- N`, only if the natural domain
    # would have exceeded that ceiling. Composes with autoscale.
    .sparkline("Ceiling", value="lift", ref=1.0, max_ylim=0.5)
    # ylim=(lo, hi) is an absolute override, replacing scale/autoscale/
    # max_ylim entirely.
    .sparkline("Override", value="lift", ref=1.0, ylim=(0.9, 1.1))
)

Forest plots take autoscale too. A single extreme interval (say a +3000% lift among sub-1% metrics) otherwise stretches the shared domain and flattens every other bar:

lifts = pl.DataFrame(
    {
        "metric": ["Checkout CVR", "Signup Rate", "Retention D7", "AOV", "Rare Event"],
        "rel": [0.03, -0.05, 0.08, 0.02, 30.0],
        "rel_lb": [0.01, -0.09, 0.04, -0.01, 22.0],
        "rel_ub": [0.05, -0.01, 0.12, 0.05, 41.0],
    }
)

(
    ct.CoefTable(lifts, rows="metric")
    .estimate("Lift %", "rel", ci=("rel_lb", "rel_ub"), fmt=ct.Percent(signed=True))
    # "robust" fits an IQR/Tukey fence over the pooled values instead of
    # the raw min/max, so the outlier stops dictating the axis. Composes
    # with symmetric= (fence first, then mirror around ref); ylim= still
    # overrides everything.
    .forest("Lift Plot", of="Lift %", ref=0.0, autoscale="robust")
)

A discounted interval is never hidden: bars in a clipping domain plot against a slightly inset region and the clipped interval continues into the reserved margin as a gradient fade, reading as "extends beyond the axis". Buckets with nothing clipped render exactly as before.

No reference, or a hidden one. ref also drives the dashed line and colour resolution; two ways to opt out, for data with no meaningful zero (revenue, durations, absolute counts):

absolute = pl.DataFrame(
    {
        "metric": ["Revenue", "Latency"],
        "value": [[282.3, 300.1, 320.0], [900.0, 910.0, 920.0]],
    }
)

(
    ct.CoefTable(absolute, rows="metric")
    # ref=None: no reference at all. No dashed line, no forced domain
    # inclusion of 0, and every cell colours neutral -- "favorable" has
    # no meaning without something to compare against.
    .sparkline("No reference", value="value", ref=None)
    # ref=0.0, show_ref=False: the reference is real and still drives
    # colour (favorable/unfavorable against 0), it just isn't drawn or
    # forced into the domain. Opt in deliberately: a mark can then claim
    # "above the reference" while the reference sits off-canvas.
    .sparkline("Hidden reference", value="value", ref=0.0, show_ref=False)
)

Plot annotations

ct.Rule draws a line and ct.Band shades an interval in a forest plot or sparkline. A numeric, date, or datetime coordinate is a literal; a string is the name of a scalar column on the main table frame. A missing field value leaves that annotation out of that row, which makes row-specific marks possible:

import polars as pl
import coeftable as ct

annotated = pl.DataFrame(
    {
        "metric": ["Revenue", "Latency"],
        "estimate": [1.2, -0.4],
        "lower": [0.8, -0.8],
        "upper": [1.6, 0.1],
        # Only Revenue receives the second vertical rule.
        "target": [1.5, None],
        "trend": [[1.0, 1.2, 1.4], [-0.1, -0.3, -0.4]],
        "guard_low": [0.9, -0.6],
        "guard_high": [1.6, 0.0],
    }
)

(
    ct.CoefTable(annotated, rows="metric")
    .estimate("Effect", "estimate", ci=("lower", "upper"))
    .forest(
        "Effect plot",
        of="Effect",
        annotations=(ct.Rule("target", axis="x"),),
    )
    .sparkline(
        "Trend",
        value="trend",
        annotations=(ct.Band("guard_low", "guard_high", axis="y"),),
    )
)

Forest annotations use axis="x" only. Sparklines accept axis="x" and axis="y"; use the former for a shared time or sequence position and the latter for a value threshold or range. layer="underlay" (the default for bands) draws before the plot; layer="overlay" (the default for rules) draws after it. affect_domain=True by default expands an automatic axis domain to include the annotation; set it to False to keep the existing domain and allow the mark to be clipped. ylim overrides the Forest x-domain and the Sparkline y-domain, so annotations on those axes do not expand them. Sparkline x annotations still participate in their shared x-domain; max_ylim can cap the Sparkline y-domain and clip or omit distant marks. Annotations supplement rather than replace ref: ref remains the built-in semantic reference that controls colors and its optional dashed line.

Metadata

Release files for coeftable 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for coeftable 0.5.0
File Size Uploaded
coeftable-0.5.0.tar.gz 715.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for coeftable 0.5.0
File Interpreter ABI Platform
coeftable-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 793.5 kB

Release files / coeftable-0.5.0.tar.gz

Download URL coeftable-0.5.0.tar.gz
Size 715.9 kB
Tags Source
SHA-256 checksum
How to use checksums
e600679b09fd0e39330a53969730234cda7ac6fafaf6000b9bb1641a9c2a35cd
BLAKE2b-256 checksum
How to use checksums
7d92cd848d73df26623abbf99d06985cd464974d7c801787b78ed3a33b769407
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 19, 2026.

Transparency log

Release files / coeftable-0.5.0-py3-none-any.whl

Download URL coeftable-0.5.0-py3-none-any.whl
Size 77.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0afdbcb0427105a7dce0b93a95f698cb6c708fb047a15f9ace3870e306f4ceb1
BLAKE2b-256 checksum
How to use checksums
09b627430d052760dd05be438d1e4cc545307b94bf9b8e7440d8505c08095525
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 19, 2026.

Transparency log

Release history Release notifications | RSS feed

0.13.1

2 release files

0.13.0

2 release files

0.12.1

2 release files

0.12.0

2 release files

0.11.0

2 release files

0.10.0

2 release files

0.9.0

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.0

2 release files

This release

0.5.0 This release

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page