coeftable 0.4.0__tar.gz → 0.5.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.5.0/AGENTS.md +7 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/CHANGELOG.md +15 -3
- {coeftable-0.4.0 → coeftable-0.5.0}/PKG-INFO +31 -1
- {coeftable-0.4.0 → coeftable-0.5.0}/README.md +30 -0
- coeftable-0.5.0/docs/images/forest-robust-autoscale.png +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/_version.py +2 -2
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/spec.py +48 -5
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/svg.py +92 -20
- {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_frame.py +88 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_svg.py +36 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/.github/release.yml +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/.github/workflows/ci.yml +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/.github/workflows/post-release.yml +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/.github/workflows/publish.yml +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/.github/workflows/release.yml +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/.gitignore +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/.pre-commit-config.yaml +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/LICENSE +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/Makefile +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/docs/images/example.png +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/docs/images/trend-example.png +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/noxfile.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/pyproject.toml +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/__init__.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/_axis.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/annotations.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/collapsible.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/errors.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/format.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/frame.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/grid.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/render.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/series.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/src/coeftable/theme.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_annotations.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_collapsible.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_format.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_package.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_public_api.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_render.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_series.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_sparkline.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_spec.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/tests/test_theme.py +0 -0
- {coeftable-0.4.0 → coeftable-0.5.0}/uv.lock +0 -0
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
<a id="v0.4.0"></a>
|
|
2
|
+
# [v0.4.0](https://github.com/kylejcaron/coeftable/releases/tag/v0.4.0) - 2026-08-14
|
|
3
|
+
|
|
4
|
+
<!-- Release notes generated using configuration in .github/release.yml at v0.4.0 -->
|
|
5
|
+
|
|
6
|
+
## What's Changed
|
|
7
|
+
### Maintenance
|
|
8
|
+
* build: align hatchling metadata floor by [@kylejcaron](https://github.com/kylejcaron) in [#30](https://github.com/kylejcaron/coeftable/pull/30)
|
|
9
|
+
* feature: support plot annotations by [@kylejcaron](https://github.com/kylejcaron) in [#31](https://github.com/kylejcaron/coeftable/pull/31)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
**Full Changelog**: https://github.com/kylejcaron/coeftable/compare/v0.3.1...v0.4.0
|
|
13
|
+
|
|
14
|
+
[Changes][v0.4.0]
|
|
2
15
|
|
|
3
|
-
### Features
|
|
4
|
-
- Add typed rule and band annotations to forest plots and sparklines, including row-specific field binding and domain-aware layering.
|
|
5
16
|
|
|
6
17
|
<a id="v0.3.1"></a>
|
|
7
18
|
# [v0.3.1](https://github.com/kylejcaron/coeftable/releases/tag/v0.3.1) - 2026-08-14
|
|
@@ -97,6 +108,7 @@
|
|
|
97
108
|
[Changes][v0.1.0]
|
|
98
109
|
|
|
99
110
|
|
|
111
|
+
[v0.4.0]: https://github.com/kylejcaron/coeftable/compare/v0.3.1...v0.4.0
|
|
100
112
|
[v0.3.1]: https://github.com/kylejcaron/coeftable/compare/v0.3.0...v0.3.1
|
|
101
113
|
[v0.3.0]: https://github.com/kylejcaron/coeftable/compare/v0.2.1...v0.3.0
|
|
102
114
|
[v0.2.1]: https://github.com/kylejcaron/coeftable/compare/v0.2.0...v0.2.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coeftable
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Publication-quality summary tables for estimates with uncertainty.
|
|
5
5
|
Project-URL: Homepage, https://github.com/kylejcaron/coeftable
|
|
6
6
|
Project-URL: Repository, https://github.com/kylejcaron/coeftable
|
|
@@ -414,6 +414,36 @@ trend = pl.DataFrame(
|
|
|
414
414
|
)
|
|
415
415
|
```
|
|
416
416
|
|
|
417
|
+
**Forest plots take `autoscale` too.** A single extreme interval (say a
|
|
418
|
+
+3000% lift among sub-1% metrics) otherwise stretches the shared domain
|
|
419
|
+
and flattens every other bar:
|
|
420
|
+
|
|
421
|
+
```python
|
|
422
|
+
lifts = pl.DataFrame(
|
|
423
|
+
{
|
|
424
|
+
"metric": ["Checkout CVR", "Signup Rate", "Retention D7", "AOV", "Rare Event"],
|
|
425
|
+
"rel": [0.03, -0.05, 0.08, 0.02, 30.0],
|
|
426
|
+
"rel_lb": [0.01, -0.09, 0.04, -0.01, 22.0],
|
|
427
|
+
"rel_ub": [0.05, -0.01, 0.12, 0.05, 41.0],
|
|
428
|
+
}
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
(
|
|
432
|
+
ct.CoefTable(lifts, rows="metric")
|
|
433
|
+
.estimate("Lift %", "rel", ci=("rel_lb", "rel_ub"), fmt=ct.Percent(signed=True))
|
|
434
|
+
# "robust" fits an IQR/Tukey fence over the pooled values instead of
|
|
435
|
+
# the raw min/max, so the outlier stops dictating the axis. Composes
|
|
436
|
+
# with symmetric= (fence first, then mirror around ref); ylim= still
|
|
437
|
+
# overrides everything.
|
|
438
|
+
.forest("Lift Plot", of="Lift %", ref=0.0, autoscale="robust")
|
|
439
|
+
)
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
A discounted interval is never hidden: bars in a clipping domain plot
|
|
443
|
+
against a slightly inset region and the clipped interval continues into
|
|
444
|
+
the reserved margin as a gradient fade, reading as "extends beyond the
|
|
445
|
+
axis". Buckets with nothing clipped render exactly as before.
|
|
446
|
+
|
|
417
447
|
**No reference, or a hidden one.** `ref` also drives the dashed line and
|
|
418
448
|
colour resolution; two ways to opt out, for data with no meaningful
|
|
419
449
|
zero (revenue, durations, absolute counts):
|
|
@@ -360,6 +360,36 @@ trend = pl.DataFrame(
|
|
|
360
360
|
)
|
|
361
361
|
```
|
|
362
362
|
|
|
363
|
+
**Forest plots take `autoscale` too.** A single extreme interval (say a
|
|
364
|
+
+3000% lift among sub-1% metrics) otherwise stretches the shared domain
|
|
365
|
+
and flattens every other bar:
|
|
366
|
+
|
|
367
|
+
```python
|
|
368
|
+
lifts = pl.DataFrame(
|
|
369
|
+
{
|
|
370
|
+
"metric": ["Checkout CVR", "Signup Rate", "Retention D7", "AOV", "Rare Event"],
|
|
371
|
+
"rel": [0.03, -0.05, 0.08, 0.02, 30.0],
|
|
372
|
+
"rel_lb": [0.01, -0.09, 0.04, -0.01, 22.0],
|
|
373
|
+
"rel_ub": [0.05, -0.01, 0.12, 0.05, 41.0],
|
|
374
|
+
}
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
(
|
|
378
|
+
ct.CoefTable(lifts, rows="metric")
|
|
379
|
+
.estimate("Lift %", "rel", ci=("rel_lb", "rel_ub"), fmt=ct.Percent(signed=True))
|
|
380
|
+
# "robust" fits an IQR/Tukey fence over the pooled values instead of
|
|
381
|
+
# the raw min/max, so the outlier stops dictating the axis. Composes
|
|
382
|
+
# with symmetric= (fence first, then mirror around ref); ylim= still
|
|
383
|
+
# overrides everything.
|
|
384
|
+
.forest("Lift Plot", of="Lift %", ref=0.0, autoscale="robust")
|
|
385
|
+
)
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
A discounted interval is never hidden: bars in a clipping domain plot
|
|
389
|
+
against a slightly inset region and the clipped interval continues into
|
|
390
|
+
the reserved margin as a gradient fade, reading as "extends beyond the
|
|
391
|
+
axis". Buckets with nothing clipped render exactly as before.
|
|
392
|
+
|
|
363
393
|
**No reference, or a hidden one.** `ref` also drives the dashed line and
|
|
364
394
|
colour resolution; two ways to opt out, for data with no meaningful
|
|
365
395
|
zero (revenue, durations, absolute counts):
|
|
Binary file
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.
|
|
22
|
-
__version_tuple__ = version_tuple = (0,
|
|
21
|
+
__version__ = version = '0.5.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 5, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -30,6 +30,7 @@ from coeftable.format import (
|
|
|
30
30
|
render_interval,
|
|
31
31
|
)
|
|
32
32
|
from coeftable.svg import (
|
|
33
|
+
CLIP_MARGIN,
|
|
33
34
|
Trace,
|
|
34
35
|
forest_axis,
|
|
35
36
|
forest_bar,
|
|
@@ -378,22 +379,24 @@ def _bucket_domain(
|
|
|
378
379
|
max_domain: float | None,
|
|
379
380
|
autoscale: Autoscale = "tight",
|
|
380
381
|
required: Sequence[float] = (),
|
|
382
|
+
symmetric: bool = False,
|
|
381
383
|
) -> tuple[float, float]:
|
|
382
|
-
"""Resolve one domain bucket for `Sparkline.prepare`.
|
|
384
|
+
"""Resolve one domain bucket for `Sparkline.prepare` or `Forest.prepare`.
|
|
383
385
|
|
|
384
386
|
`override` wins outright; otherwise a tight fit uses all `values` and
|
|
385
387
|
a robust fit first applies an IQR/Tukey fence. Required annotation
|
|
386
388
|
coordinates are joined to those retained data inputs before one padding
|
|
387
389
|
pass, so a coordinate within the retained data extent leaves the
|
|
388
390
|
automatic fit unchanged. `max_domain`, when set, then narrows the result
|
|
389
|
-
around `ref`.
|
|
391
|
+
around `ref`. `symmetric=True` mirrors the padded fit around `ref`
|
|
392
|
+
(`Forest`'s option; requires `ref`, enforced by `_pad_domain`).
|
|
390
393
|
"""
|
|
391
394
|
if override is not None:
|
|
392
395
|
return override
|
|
393
396
|
fit_values = _robust_inliers(values) if autoscale == "robust" else values
|
|
394
397
|
finite_required = _finite(list(required)) if required else []
|
|
395
398
|
inputs = [*fit_values, *finite_required] if finite_required else fit_values
|
|
396
|
-
domain = _pad_domain(inputs, ref)
|
|
399
|
+
domain = _pad_domain(inputs, ref, symmetric=symmetric)
|
|
397
400
|
return _clamp_domain(domain, ref, max_domain) if max_domain is not None else domain
|
|
398
401
|
|
|
399
402
|
|
|
@@ -453,6 +456,7 @@ class _ForestState:
|
|
|
453
456
|
value: list[float | None]
|
|
454
457
|
low: list[float | None]
|
|
455
458
|
high: list[float | None]
|
|
459
|
+
clipped: frozenset[Any]
|
|
456
460
|
annotations: PreparedAnnotations
|
|
457
461
|
|
|
458
462
|
|
|
@@ -475,6 +479,16 @@ class Forest:
|
|
|
475
479
|
symmetric
|
|
476
480
|
When `ylim` is not set, symmetrize the auto-computed domain
|
|
477
481
|
around `ref` instead of fitting tightly to the data.
|
|
482
|
+
autoscale
|
|
483
|
+
Strategy for the auto-computed domain when `ylim` is not set.
|
|
484
|
+
`"tight"` (default) fits to the exact min/max of the bucket's
|
|
485
|
+
pooled values. `"robust"` fits an IQR/Tukey fence over them
|
|
486
|
+
instead, so a single extreme interval stops flattening every
|
|
487
|
+
other bar; a discounted interval clips against the resulting
|
|
488
|
+
domain edge and continues into a reserved margin as a gradient
|
|
489
|
+
fade rather than being hidden. Colour resolution is unaffected --
|
|
490
|
+
it always reads a row's true values, never the domain they are
|
|
491
|
+
plotted against.
|
|
478
492
|
width
|
|
479
493
|
Bar width in pixels.
|
|
480
494
|
height
|
|
@@ -496,6 +510,7 @@ class Forest:
|
|
|
496
510
|
scale: Scale = "table"
|
|
497
511
|
ylim: tuple[float, float] | None = None
|
|
498
512
|
symmetric: bool = False
|
|
513
|
+
autoscale: Autoscale = "tight"
|
|
499
514
|
width: int = 220
|
|
500
515
|
height: int | None = None
|
|
501
516
|
show_axis: bool = True
|
|
@@ -525,16 +540,30 @@ class Forest:
|
|
|
525
540
|
)
|
|
526
541
|
|
|
527
542
|
buckets: dict[Any, list[float]] = {}
|
|
543
|
+
required: dict[Any, list[float]] = {}
|
|
528
544
|
for i in range(len(scan.row_keys)):
|
|
529
545
|
key = _domain_key(self, scan.row_keys[i], scan.group_keys[i], scan.split_keys[i])
|
|
530
546
|
inputs = buckets.setdefault(key, [])
|
|
531
547
|
inputs.extend(_finite([value[i], low[i], high[i]]))
|
|
532
548
|
if not is_missing(value[i]):
|
|
533
|
-
|
|
549
|
+
required.setdefault(key, []).extend(domain_values(annotations.by_row[i], axis="x"))
|
|
534
550
|
domains = {
|
|
535
|
-
key:
|
|
551
|
+
key: _bucket_domain(
|
|
552
|
+
vals,
|
|
553
|
+
self.ref,
|
|
554
|
+
override=self.ylim,
|
|
555
|
+
max_domain=None,
|
|
556
|
+
autoscale=self.autoscale,
|
|
557
|
+
required=required.get(key, ()),
|
|
558
|
+
symmetric=self.symmetric,
|
|
559
|
+
)
|
|
536
560
|
for key, vals in buckets.items()
|
|
537
561
|
}
|
|
562
|
+
clipped = frozenset(
|
|
563
|
+
key
|
|
564
|
+
for key, vals in buckets.items()
|
|
565
|
+
if any(v < domains[key][0] or v > domains[key][1] for v in vals)
|
|
566
|
+
)
|
|
538
567
|
|
|
539
568
|
def footer_key(row_key: Any, group: Any, split: Any) -> Any:
|
|
540
569
|
return _domain_key(self, row_key, group, split)
|
|
@@ -547,6 +576,7 @@ class Forest:
|
|
|
547
576
|
low=low,
|
|
548
577
|
high=high,
|
|
549
578
|
annotations=annotations,
|
|
579
|
+
clipped=clipped,
|
|
550
580
|
),
|
|
551
581
|
footer_key=footer_key if self.show_axis else None,
|
|
552
582
|
shared_footer=self.scale in ("table", "split_column"),
|
|
@@ -573,6 +603,7 @@ class Forest:
|
|
|
573
603
|
theme=ctx.theme,
|
|
574
604
|
width=self.width,
|
|
575
605
|
height=_plot_height((state.source,), self.height),
|
|
606
|
+
margin=CLIP_MARGIN if key in state.clipped else 0,
|
|
576
607
|
annotations=state.annotations.by_row[ctx.index],
|
|
577
608
|
)
|
|
578
609
|
|
|
@@ -585,6 +616,7 @@ class Forest:
|
|
|
585
616
|
fmt=self.axis_fmt or state.source.fmt,
|
|
586
617
|
theme=ctx.theme,
|
|
587
618
|
width=self.width,
|
|
619
|
+
margin=CLIP_MARGIN if ctx.key in state.clipped else 0,
|
|
588
620
|
)
|
|
589
621
|
|
|
590
622
|
|
|
@@ -1371,6 +1403,7 @@ class CoefTable:
|
|
|
1371
1403
|
scale: Scale = "table",
|
|
1372
1404
|
ylim: tuple[float, float] | None = None,
|
|
1373
1405
|
symmetric: bool = False,
|
|
1406
|
+
autoscale: Autoscale = "tight",
|
|
1374
1407
|
width: int = 220,
|
|
1375
1408
|
height: int | None = None,
|
|
1376
1409
|
show_axis: bool = True,
|
|
@@ -1394,6 +1427,15 @@ class CoefTable:
|
|
|
1394
1427
|
symmetric
|
|
1395
1428
|
When `ylim` is not set, symmetrize the auto-computed domain
|
|
1396
1429
|
around `ref` instead of fitting tightly to the data.
|
|
1430
|
+
autoscale
|
|
1431
|
+
Strategy for the auto-computed domain when `ylim` is not set.
|
|
1432
|
+
`"tight"` (default) fits to the exact min/max of the bucket's
|
|
1433
|
+
pooled values. `"robust"` fits an IQR/Tukey fence over them
|
|
1434
|
+
instead, so a single extreme interval stops flattening every
|
|
1435
|
+
other bar; a discounted interval clips against the resulting
|
|
1436
|
+
domain edge and continues into a reserved margin as a
|
|
1437
|
+
gradient fade rather than being hidden. Composes with
|
|
1438
|
+
`symmetric`: the fence runs first, then the mirrored padding.
|
|
1397
1439
|
width
|
|
1398
1440
|
Bar width in pixels.
|
|
1399
1441
|
height
|
|
@@ -1420,6 +1462,7 @@ class CoefTable:
|
|
|
1420
1462
|
scale=scale,
|
|
1421
1463
|
ylim=ylim,
|
|
1422
1464
|
symmetric=symmetric,
|
|
1465
|
+
autoscale=autoscale,
|
|
1423
1466
|
width=width,
|
|
1424
1467
|
height=height,
|
|
1425
1468
|
show_axis=show_axis,
|
|
@@ -849,6 +849,48 @@ def _esc(text: str) -> str:
|
|
|
849
849
|
return html.escape(text, quote=False)
|
|
850
850
|
|
|
851
851
|
|
|
852
|
+
CLIP_MARGIN = 18
|
|
853
|
+
# Pixel reserve on each side of a forest bar's plotting region when its
|
|
854
|
+
# domain bucket contains clipped values -- the strip a `_fade_cap` bleeds
|
|
855
|
+
# into. Shared with `spec.Forest`, which decides per bucket whether to
|
|
856
|
+
# reserve it at all.
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _fade_id(color: str, flip: bool) -> str:
|
|
860
|
+
"""Return a deterministic id for a `_fade_cap` gradient.
|
|
861
|
+
|
|
862
|
+
Same collision reasoning as `_hard_clip_id`: SVG ids share one
|
|
863
|
+
namespace across an embedding page, so the id is built from the
|
|
864
|
+
gradient's full content -- its colour and direction. Two calls share
|
|
865
|
+
an id exactly when their gradients are identical, which makes any
|
|
866
|
+
collision harmless.
|
|
867
|
+
"""
|
|
868
|
+
safe = "".join(c if c.isalnum() else "_" for c in color)
|
|
869
|
+
return f"fade{'l' if flip else 'r'}_{safe}"
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _fade_cap(
|
|
873
|
+
x: float, top: float, fade_width: float, bar_height: int, color: str, *, flip: bool
|
|
874
|
+
) -> str:
|
|
875
|
+
"""Continue a clipped bar into the reserved margin as a gradient fade.
|
|
876
|
+
|
|
877
|
+
The rectangle starts at the domain edge the bar clipped against and
|
|
878
|
+
fades from the bar's own fill to transparent across `fade_width` px,
|
|
879
|
+
reading as "extends beyond the axis" rather than ending abruptly.
|
|
880
|
+
`flip=True` fades leftward for a low-edge clip.
|
|
881
|
+
"""
|
|
882
|
+
gid = _fade_id(color, flip)
|
|
883
|
+
x_attrs = 'x1="1" y1="0" x2="0" y2="0"' if flip else 'x1="0" y1="0" x2="1" y2="0"'
|
|
884
|
+
return (
|
|
885
|
+
f'<defs><linearGradient id="{gid}" {x_attrs}>'
|
|
886
|
+
f'<stop offset="0" stop-color="{color}" stop-opacity="0.75"/>'
|
|
887
|
+
f'<stop offset="1" stop-color="{color}" stop-opacity="0"/>'
|
|
888
|
+
f"</linearGradient></defs>"
|
|
889
|
+
f'<rect x="{x:.2f}" y="{top:.2f}" width="{fade_width:.2f}" '
|
|
890
|
+
f'height="{bar_height}" fill="url(#{gid})"/>'
|
|
891
|
+
)
|
|
892
|
+
|
|
893
|
+
|
|
852
894
|
def forest_bar(
|
|
853
895
|
estimate: float | None,
|
|
854
896
|
lower: float | None,
|
|
@@ -862,14 +904,18 @@ def forest_bar(
|
|
|
862
904
|
height: int = 18,
|
|
863
905
|
bar_height: int = 9,
|
|
864
906
|
inset: int = 3,
|
|
907
|
+
margin: int = 0,
|
|
865
908
|
annotations: Sequence[ResolvedAnnotation] = (),
|
|
866
909
|
) -> str:
|
|
867
910
|
"""Render one interval as an inline SVG bar.
|
|
868
911
|
|
|
869
912
|
The bar spans the interval, a light tick marks the point estimate, and a
|
|
870
913
|
dashed line marks `ref` when it falls inside `domain`. A bound outside the
|
|
871
|
-
domain, including an unbounded one,
|
|
872
|
-
|
|
914
|
+
domain, including an unbounded one, is clipped visibly rather than
|
|
915
|
+
silently: with `margin=0` it draws to the edge with a triangular cap;
|
|
916
|
+
with `margin > 0` the bars plot against the inner
|
|
917
|
+
`[margin, width - margin]` region and a clipped bound continues into
|
|
918
|
+
the margin as a gradient fade to transparent (see `_fade_cap`).
|
|
873
919
|
|
|
874
920
|
Parameters
|
|
875
921
|
----------
|
|
@@ -889,6 +935,10 @@ def forest_bar(
|
|
|
889
935
|
Resolved x-axis rules and bands rendered around the existing bar.
|
|
890
936
|
width, height, bar_height, inset
|
|
891
937
|
Geometry in pixels.
|
|
938
|
+
margin
|
|
939
|
+
Pixel reserve on each side of the plotting region for the fade
|
|
940
|
+
treatment of clipped bounds. `0` (the default) keeps the full
|
|
941
|
+
width for bars and marks clipping with triangular caps instead.
|
|
892
942
|
|
|
893
943
|
Returns
|
|
894
944
|
-------
|
|
@@ -896,7 +946,12 @@ def forest_bar(
|
|
|
896
946
|
A complete ``<svg>`` element.
|
|
897
947
|
"""
|
|
898
948
|
low, high = domain
|
|
899
|
-
|
|
949
|
+
plot_width = width - 2 * margin
|
|
950
|
+
inner_project = _projector(domain, plot_width, inset)
|
|
951
|
+
|
|
952
|
+
def project(v: float) -> float:
|
|
953
|
+
return margin + inner_project(v)
|
|
954
|
+
|
|
900
955
|
low_value = low if lower is None or is_missing(lower) else lower
|
|
901
956
|
high_value = high if upper is None or is_missing(upper) else upper
|
|
902
957
|
clipped_low = is_missing(lower) or low_value < low
|
|
@@ -928,19 +983,26 @@ def forest_bar(
|
|
|
928
983
|
f'y2="{top + bar_height:.2f}" stroke="{theme.surface}" stroke-width="1.5"/>'
|
|
929
984
|
)
|
|
930
985
|
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
986
|
+
if margin:
|
|
987
|
+
fade_width = float(margin - inset)
|
|
988
|
+
if clipped_high:
|
|
989
|
+
parts.append(_fade_cap(x1, top, fade_width, bar_height, color, flip=False))
|
|
990
|
+
if clipped_low:
|
|
991
|
+
parts.append(_fade_cap(x0 - fade_width, top, fade_width, bar_height, color, flip=True))
|
|
992
|
+
else:
|
|
993
|
+
cap = bar_height * 0.6
|
|
994
|
+
if clipped_high:
|
|
995
|
+
tip = width - inset / 2
|
|
996
|
+
parts.append(
|
|
997
|
+
f'<polygon points="{tip:.2f},{middle:.2f} {tip - cap:.2f},{middle - cap:.2f} '
|
|
998
|
+
f'{tip - cap:.2f},{middle + cap:.2f}" fill="{color}"/>'
|
|
999
|
+
)
|
|
1000
|
+
if clipped_low:
|
|
1001
|
+
tip = inset / 2
|
|
1002
|
+
parts.append(
|
|
1003
|
+
f'<polygon points="{tip:.2f},{middle:.2f} {tip + cap:.2f},{middle - cap:.2f} '
|
|
1004
|
+
f'{tip + cap:.2f},{middle + cap:.2f}" fill="{color}"/>'
|
|
1005
|
+
)
|
|
944
1006
|
if not annotations:
|
|
945
1007
|
return _svg(width, height, "".join(parts))
|
|
946
1008
|
area = _PlotArea(
|
|
@@ -948,8 +1010,8 @@ def forest_bar(
|
|
|
948
1010
|
y_domain=None,
|
|
949
1011
|
project_x=project,
|
|
950
1012
|
project_y=None,
|
|
951
|
-
left=inset,
|
|
952
|
-
right=width - inset,
|
|
1013
|
+
left=margin + inset,
|
|
1014
|
+
right=width - margin - inset,
|
|
953
1015
|
top=0,
|
|
954
1016
|
bottom=height,
|
|
955
1017
|
)
|
|
@@ -1078,6 +1140,7 @@ def forest_axis(
|
|
|
1078
1140
|
width: int = 220,
|
|
1079
1141
|
height: int = 22,
|
|
1080
1142
|
inset: int = 3,
|
|
1143
|
+
margin: int = 0,
|
|
1081
1144
|
target_ticks: int = 4,
|
|
1082
1145
|
) -> str:
|
|
1083
1146
|
"""Render the shared x-axis for a set of forest bars.
|
|
@@ -1094,6 +1157,10 @@ def forest_axis(
|
|
|
1094
1157
|
Supplies the axis colour and label size.
|
|
1095
1158
|
width, height, inset
|
|
1096
1159
|
Geometry in pixels.
|
|
1160
|
+
margin
|
|
1161
|
+
Pixel reserve on each side matching the bars' own `margin`; the
|
|
1162
|
+
axis spine and projection span only the inner region so ticks
|
|
1163
|
+
stay aligned with the bars.
|
|
1097
1164
|
target_ticks
|
|
1098
1165
|
Approximate number of ticks wanted.
|
|
1099
1166
|
|
|
@@ -1103,10 +1170,15 @@ def forest_axis(
|
|
|
1103
1170
|
A complete ``<svg>`` element.
|
|
1104
1171
|
"""
|
|
1105
1172
|
low, high = domain
|
|
1106
|
-
|
|
1173
|
+
inner_project = _projector(domain, width - 2 * margin, inset)
|
|
1174
|
+
|
|
1175
|
+
def project(v: float) -> float:
|
|
1176
|
+
return margin + inner_project(v)
|
|
1177
|
+
|
|
1107
1178
|
baseline = 4.0
|
|
1108
1179
|
parts = [
|
|
1109
|
-
f'<line x1="{
|
|
1180
|
+
f'<line x1="{margin + inset}" y1="{baseline:.2f}" '
|
|
1181
|
+
f'x2="{width - margin - inset}" y2="{baseline:.2f}" '
|
|
1110
1182
|
f'stroke="{theme.axis}" stroke-width="0.75"/>'
|
|
1111
1183
|
]
|
|
1112
1184
|
if low <= ref <= high:
|
|
@@ -562,6 +562,94 @@ def test_explicit_ylim_wins_over_symmetric():
|
|
|
562
562
|
assert out.axis_rows
|
|
563
563
|
|
|
564
564
|
|
|
565
|
+
OUTLIER_RAW = {
|
|
566
|
+
"area": ["Core"] * 4,
|
|
567
|
+
"metric": ["A", "B", "C", "Rare"],
|
|
568
|
+
"variant": ["B", "B", "B", "B"],
|
|
569
|
+
"rel": [0.5, -0.2, 0.1, 300.0],
|
|
570
|
+
"rel_lb": [0.3, -0.5, -0.1, 220.0],
|
|
571
|
+
"rel_ub": [0.7, 0.1, 0.3, 410.0],
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def test_forest_autoscale_robust_discounts_outlier_and_flags_bucket_clipped():
|
|
576
|
+
table = base(pl.DataFrame(OUTLIER_RAW)).forest("Plot", of="Lift %", autoscale="robust")
|
|
577
|
+
state = _forest_state(table)
|
|
578
|
+
(domain,) = state.domains.values()
|
|
579
|
+
assert domain[1] < 220.0
|
|
580
|
+
assert state.clipped == set(state.domains)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def test_forest_autoscale_tight_default_fits_outlier_with_no_clip():
|
|
584
|
+
table = base(pl.DataFrame(OUTLIER_RAW)).forest("Plot", of="Lift %")
|
|
585
|
+
state = _forest_state(table)
|
|
586
|
+
(domain,) = state.domains.values()
|
|
587
|
+
assert domain[1] >= 410.0
|
|
588
|
+
assert state.clipped == frozenset()
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def test_forest_autoscale_robust_composes_with_symmetric():
|
|
592
|
+
table = base(pl.DataFrame(OUTLIER_RAW)).forest(
|
|
593
|
+
"Plot", of="Lift %", ref=0.0, autoscale="robust", symmetric=True
|
|
594
|
+
)
|
|
595
|
+
state = _forest_state(table)
|
|
596
|
+
(domain,) = state.domains.values()
|
|
597
|
+
assert domain[0] == -domain[1]
|
|
598
|
+
assert domain[1] < 220.0
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def test_forest_explicit_ylim_wins_over_robust_and_still_flags_clipping():
|
|
602
|
+
table = base(pl.DataFrame(OUTLIER_RAW)).forest(
|
|
603
|
+
"Plot", of="Lift %", autoscale="robust", ylim=(-1.0, 1.0)
|
|
604
|
+
)
|
|
605
|
+
state = _forest_state(table)
|
|
606
|
+
(domain,) = state.domains.values()
|
|
607
|
+
assert domain == (-1.0, 1.0)
|
|
608
|
+
assert state.clipped == set(state.domains)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def test_forest_clipped_bucket_renders_margin_fade_in_html():
|
|
612
|
+
table = base(pl.DataFrame(OUTLIER_RAW)).forest("Plot", of="Lift %", autoscale="robust")
|
|
613
|
+
html = table.gt()._repr_html_()
|
|
614
|
+
assert "linearGradient" in html
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def test_forest_unclipped_table_renders_no_fade_in_html():
|
|
618
|
+
table = base(pl.DataFrame(RAW)).forest("Plot", of="Lift %")
|
|
619
|
+
html = table.gt()._repr_html_()
|
|
620
|
+
assert "linearGradient" not in html
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def test_forest_robust_domain_retains_annotation_outside_inlier_range():
|
|
624
|
+
# Under autoscale="robust" a forest annotation coordinate rides the
|
|
625
|
+
# `required` join past the IQR fence: it expands the domain even though a
|
|
626
|
+
# genuine data outlier in the same bucket is fenced out. The two live
|
|
627
|
+
# bounds pin both halves -- the annotation (2.0) is retained while the
|
|
628
|
+
# data outlier (6.0) is discounted, so the domain lands near 2.0, not 6.0.
|
|
629
|
+
raw = pl.DataFrame(
|
|
630
|
+
{
|
|
631
|
+
"metric": ["A", "B", "C", "D", "E"],
|
|
632
|
+
"est": [0.30, 0.32, 0.28, 0.31, 5.00],
|
|
633
|
+
"low": [0.20, 0.22, 0.18, 0.21, 4.00],
|
|
634
|
+
"high": [0.40, 0.42, 0.38, 0.41, 6.00],
|
|
635
|
+
}
|
|
636
|
+
)
|
|
637
|
+
table = (
|
|
638
|
+
CoefTable(raw, rows="metric")
|
|
639
|
+
.estimate("Effect", "est", ci=("low", "high"))
|
|
640
|
+
.forest(
|
|
641
|
+
"Plot",
|
|
642
|
+
of="Effect",
|
|
643
|
+
ref=0.0,
|
|
644
|
+
autoscale="robust",
|
|
645
|
+
annotations=(Rule(2.0, axis="x"),),
|
|
646
|
+
)
|
|
647
|
+
)
|
|
648
|
+
_, high = _forest_state(table).domains[("table",)]
|
|
649
|
+
assert high >= 2.0 # annotation retained past the fence
|
|
650
|
+
assert high < 4.0 # data outlier fenced out, not driving the domain
|
|
651
|
+
|
|
652
|
+
|
|
565
653
|
def test_plot_height_picks_stacked_layout_default():
|
|
566
654
|
estimate = Estimate("Lift %", "rel", ci=("lb", "ub"))
|
|
567
655
|
forest = Forest("Plot", of="Lift %")
|
|
@@ -124,6 +124,42 @@ def test_forest_bar_without_clipping_has_no_cap():
|
|
|
124
124
|
assert "<polygon" not in svg
|
|
125
125
|
|
|
126
126
|
|
|
127
|
+
def test_forest_bar_margin_fades_clipped_upper_bound_instead_of_cap():
|
|
128
|
+
svg = forest_bar(
|
|
129
|
+
1.0, 0.5, 99.0, domain=(0.0, 2.0), ref=0.0, color="#000", theme=DEFAULT, margin=18
|
|
130
|
+
)
|
|
131
|
+
assert "linearGradient" in svg
|
|
132
|
+
assert "<polygon" not in svg
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_forest_bar_margin_fades_clipped_lower_bound_with_flipped_gradient():
|
|
136
|
+
svg = forest_bar(
|
|
137
|
+
1.0, -99.0, 1.5, domain=(0.0, 2.0), ref=0.0, color="#000", theme=DEFAULT, margin=18
|
|
138
|
+
)
|
|
139
|
+
assert 'id="fadel_' in svg
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_forest_bar_margin_without_clipping_draws_no_fade():
|
|
143
|
+
svg = forest_bar(
|
|
144
|
+
1.0, 0.5, 1.5, domain=(0.0, 2.0), ref=0.0, color="#000", theme=DEFAULT, margin=18
|
|
145
|
+
)
|
|
146
|
+
assert "linearGradient" not in svg
|
|
147
|
+
assert "<polygon" not in svg
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_forest_bar_margin_offsets_projection_into_inner_region():
|
|
151
|
+
# A bar spanning the whole domain starts at margin + inset, not inset.
|
|
152
|
+
svg = forest_bar(
|
|
153
|
+
1.0, 0.0, 2.0, domain=(0.0, 2.0), ref=0.0, color="#000", theme=DEFAULT, margin=18
|
|
154
|
+
)
|
|
155
|
+
assert 'x="21.00"' in svg
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def test_forest_axis_margin_spans_inner_region():
|
|
159
|
+
axis = forest_axis(domain=(0.0, 2.0), ref=0.0, fmt=Number(), theme=DEFAULT, margin=18)
|
|
160
|
+
assert 'x1="21"' in axis
|
|
161
|
+
|
|
162
|
+
|
|
127
163
|
def test_forest_bar_survives_zero_width_domain():
|
|
128
164
|
svg = forest_bar(1.0, 1.0, 1.0, domain=(1.0, 1.0), ref=0.0, color="#000", theme=DEFAULT)
|
|
129
165
|
assert svg.startswith("<svg")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|