workforce-plotting 0.3.0__tar.gz → 0.4.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.
- {workforce_plotting-0.3.0/src/workforce_plotting.egg-info → workforce_plotting-0.4.0}/PKG-INFO +38 -17
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/README.md +37 -16
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/pyproject.toml +1 -1
- workforce_plotting-0.4.0/src/workforce_plotting/figures.py +394 -0
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0/src/workforce_plotting.egg-info}/PKG-INFO +38 -17
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/tests/test_plots.py +161 -6
- workforce_plotting-0.3.0/src/workforce_plotting/figures.py +0 -443
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/LICENSE +0 -0
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/MANIFEST.in +0 -0
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/examples/default_examples.py +0 -0
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/examples/paper_examples.py +0 -0
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/setup.cfg +0 -0
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/src/workforce_plotting/__init__.py +0 -0
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/src/workforce_plotting.egg-info/SOURCES.txt +0 -0
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/src/workforce_plotting.egg-info/dependency_links.txt +0 -0
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/src/workforce_plotting.egg-info/requires.txt +0 -0
- {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/src/workforce_plotting.egg-info/top_level.txt +0 -0
{workforce_plotting-0.3.0/src/workforce_plotting.egg-info → workforce_plotting-0.4.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: workforce-plotting
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Simple plotnine figures with Opportunity Insights styling.
|
|
5
5
|
Author: Corey Gelb-Bicknell
|
|
6
6
|
License-Expression: MIT
|
|
@@ -53,8 +53,7 @@ conversion dependency. Pandas input needs no extra.
|
|
|
53
53
|
|
|
54
54
|
## Getting help
|
|
55
55
|
|
|
56
|
-
Function help
|
|
57
|
-
and a short example:
|
|
56
|
+
Function help describes the arguments and shows a short example.
|
|
58
57
|
|
|
59
58
|
```python
|
|
60
59
|
from workforce_plotting import plot_lines
|
|
@@ -84,8 +83,10 @@ plotnine plot, with no automatic saving.
|
|
|
84
83
|
Its points remain unconnected even when a separate fitted curve is supplied.
|
|
85
84
|
`plot_validation` accepts `x_lower`, `x_upper`, `y_lower`, `y_upper`,
|
|
86
85
|
`gaps="horizontal"` or `"vertical"`, and a common `(min, max)` pair of `limits`.
|
|
86
|
+
Use `plot_validation(df, x="dml", y="rct", series="program_name")`
|
|
87
|
+
to color the estimates, intervals, and gap lines by program.
|
|
87
88
|
The identity line has slope one in data units; the panel follows the figure's
|
|
88
|
-
aspect ratio.
|
|
89
|
+
aspect ratio. All five functions accept `series` for discrete groups. Use ordered
|
|
89
90
|
categorical columns to control color/legend order. Colors are teal, orange, navy,
|
|
90
91
|
purple, blue, red, and dark green from the public `oi_tools.figures.OIColors` API.
|
|
91
92
|
For a different mapping, add a plotnine manual color or fill scale.
|
|
@@ -107,29 +108,40 @@ the scatter data. No smoothing, RMSE calculation, confidence-interval estimation
|
|
|
107
108
|
or automatic saving happens inside the plotting functions.
|
|
108
109
|
|
|
109
110
|
Add `pn.coord_flip()` for horizontal bars/intervals; use ordinary plotnine scales
|
|
110
|
-
for dollars, percentages, limits, or log axes. The default font is Times New Roman
|
|
111
|
+
for dollars, percentages, limits, or log axes. The default font is Times New Roman when installed. The theme checks for
|
|
112
|
+
DejaVu Serif, DejaVu Sans, STIXGeneral, and other available families before
|
|
113
|
+
rendering. An empty font list triggers registration of the bundled DejaVu Serif.
|
|
111
114
|
Override fonts, dimensions, or text size with
|
|
112
115
|
`theme_workforce(base_family=..., base_size=..., width=..., height=...)`.
|
|
113
116
|
For example, `base_family="DejaVu Sans"` selects sans-serif type.
|
|
114
|
-
|
|
117
|
+
Use `title=` for a plot title. Add logos, notes, and paper-specific annotations
|
|
118
|
+
with plotnine.
|
|
115
119
|
|
|
116
120
|
## Paper defaults
|
|
117
121
|
|
|
118
|
-
- Times New Roman, 12-point text, 1.2 line spacing, and an
|
|
122
|
+
- Times New Roman when available, 12-point text, 1.2 line spacing, and an
|
|
123
|
+
8-by-5-inch canvas.
|
|
119
124
|
- Gray 0.54-point axes/ticks, 10-point axis-title padding in both directions,
|
|
120
|
-
2% outer margins,
|
|
125
|
+
2% outer margins, 4.14-point ticks, no grid, and an inside upper-left vertical
|
|
121
126
|
legend with a transparent background.
|
|
122
127
|
- Dots use plotnine size 3.75. Legend dots are 25% larger; connected-line plots
|
|
123
128
|
show dots in the legend when points are enabled and lines otherwise.
|
|
124
129
|
- Confidence intervals use a 0.54-point stroke. Dots and interval strokes are
|
|
125
130
|
separate layers, so changing one does not change the other.
|
|
126
131
|
|
|
132
|
+
All five functions accept `title=` for a centered plot title. Grouped bars,
|
|
133
|
+
validation plots, and intervals on categorical axes put the legend above the panel.
|
|
134
|
+
For longer category names, supply
|
|
135
|
+
labels with line breaks or use `scale_x_discrete(labels=...)` to wrap them.
|
|
136
|
+
Datetime x-axes covering less than a day show clock times in the data's time zone.
|
|
137
|
+
|
|
127
138
|
Point-based functions accept `point_size`. Those with intervals also accept
|
|
128
|
-
`ci_width` (
|
|
129
|
-
perpendicular to the interval).
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
139
|
+
`ci_width` (stroke width in points) and `cap_width` (plot-axis units
|
|
140
|
+
perpendicular to the interval). Caps default to 1% of the numeric axis span
|
|
141
|
+
or 0.12 category units on a categorical axis. A single numeric x position
|
|
142
|
+
has no default caps. On a log axis, the cap width
|
|
143
|
+
uses the transformed axis units. Set `cap_width=0` to omit caps. Grouped
|
|
144
|
+
`plot_intervals` narrows the default caps when x positions are closely spaced.
|
|
133
145
|
|
|
134
146
|
Dollar formatting is optional, rather than imposed on every outcome:
|
|
135
147
|
|
|
@@ -140,15 +152,14 @@ p += pn.scale_y_continuous(labels=dollars) # -$1K, $0, $2.5K, ...
|
|
|
140
152
|
```
|
|
141
153
|
|
|
142
154
|
`plot_validation` keeps its existing name and rectangular shape. Both axes
|
|
143
|
-
share numeric limits, but equal physical scaling is not imposed.
|
|
144
|
-
line-width defaults are unchanged.
|
|
155
|
+
share numeric limits, but equal physical scaling is not imposed.
|
|
145
156
|
|
|
146
157
|
Titles remain ordinary plotnine labels. Move the legend without changing the
|
|
147
158
|
plot constructor or shared theme:
|
|
148
159
|
|
|
149
160
|
```python
|
|
150
161
|
p += pn.labs(title="Earnings Before and After Enrollment")
|
|
151
|
-
p += pn.theme(legend_position_inside=(0.97, 0.97),
|
|
162
|
+
p += pn.theme(legend_position="inside", legend_position_inside=(0.97, 0.97),
|
|
152
163
|
legend_justification_inside=(1, 1)) # upper-right
|
|
153
164
|
```
|
|
154
165
|
|
|
@@ -172,7 +183,9 @@ p = (
|
|
|
172
183
|
|
|
173
184
|
For unconnected cohort estimates plus a supplied curve, use
|
|
174
185
|
`plot_scatter(df, x="year", y="effect", lower="lower", upper="upper", fitted=curve)`.
|
|
175
|
-
For estimates across categories
|
|
186
|
+
For estimates across categories, use `plot_intervals`. Pass `fitted=` to draw a
|
|
187
|
+
curve from supplied values. For a single series, a supplied curve appears in teal
|
|
188
|
+
with gray points in both `plot_intervals` and `plot_scatter`.
|
|
176
189
|
|
|
177
190
|
## Styling your own plots
|
|
178
191
|
|
|
@@ -224,6 +237,14 @@ python examples/paper_examples.py outputs/examples
|
|
|
224
237
|
pytest
|
|
225
238
|
```
|
|
226
239
|
|
|
240
|
+
## Changes in 0.4
|
|
241
|
+
|
|
242
|
+
- Validation plots accept `series=` to color by program or another grouping column.
|
|
243
|
+
- Plot titles are larger and centered, confidence intervals have short caps by
|
|
244
|
+
default, and interval plots accept a supplied fit.
|
|
245
|
+
- Numeric group labels, sparse groups, missing estimates, and intraday axes
|
|
246
|
+
render more reliably.
|
|
247
|
+
|
|
227
248
|
## Changes in 0.3
|
|
228
249
|
|
|
229
250
|
- Paper defaults: Times New Roman, inside vertical legends, five-point ticks,
|
|
@@ -29,8 +29,7 @@ conversion dependency. Pandas input needs no extra.
|
|
|
29
29
|
|
|
30
30
|
## Getting help
|
|
31
31
|
|
|
32
|
-
Function help
|
|
33
|
-
and a short example:
|
|
32
|
+
Function help describes the arguments and shows a short example.
|
|
34
33
|
|
|
35
34
|
```python
|
|
36
35
|
from workforce_plotting import plot_lines
|
|
@@ -60,8 +59,10 @@ plotnine plot, with no automatic saving.
|
|
|
60
59
|
Its points remain unconnected even when a separate fitted curve is supplied.
|
|
61
60
|
`plot_validation` accepts `x_lower`, `x_upper`, `y_lower`, `y_upper`,
|
|
62
61
|
`gaps="horizontal"` or `"vertical"`, and a common `(min, max)` pair of `limits`.
|
|
62
|
+
Use `plot_validation(df, x="dml", y="rct", series="program_name")`
|
|
63
|
+
to color the estimates, intervals, and gap lines by program.
|
|
63
64
|
The identity line has slope one in data units; the panel follows the figure's
|
|
64
|
-
aspect ratio.
|
|
65
|
+
aspect ratio. All five functions accept `series` for discrete groups. Use ordered
|
|
65
66
|
categorical columns to control color/legend order. Colors are teal, orange, navy,
|
|
66
67
|
purple, blue, red, and dark green from the public `oi_tools.figures.OIColors` API.
|
|
67
68
|
For a different mapping, add a plotnine manual color or fill scale.
|
|
@@ -83,29 +84,40 @@ the scatter data. No smoothing, RMSE calculation, confidence-interval estimation
|
|
|
83
84
|
or automatic saving happens inside the plotting functions.
|
|
84
85
|
|
|
85
86
|
Add `pn.coord_flip()` for horizontal bars/intervals; use ordinary plotnine scales
|
|
86
|
-
for dollars, percentages, limits, or log axes. The default font is Times New Roman
|
|
87
|
+
for dollars, percentages, limits, or log axes. The default font is Times New Roman when installed. The theme checks for
|
|
88
|
+
DejaVu Serif, DejaVu Sans, STIXGeneral, and other available families before
|
|
89
|
+
rendering. An empty font list triggers registration of the bundled DejaVu Serif.
|
|
87
90
|
Override fonts, dimensions, or text size with
|
|
88
91
|
`theme_workforce(base_family=..., base_size=..., width=..., height=...)`.
|
|
89
92
|
For example, `base_family="DejaVu Sans"` selects sans-serif type.
|
|
90
|
-
|
|
93
|
+
Use `title=` for a plot title. Add logos, notes, and paper-specific annotations
|
|
94
|
+
with plotnine.
|
|
91
95
|
|
|
92
96
|
## Paper defaults
|
|
93
97
|
|
|
94
|
-
- Times New Roman, 12-point text, 1.2 line spacing, and an
|
|
98
|
+
- Times New Roman when available, 12-point text, 1.2 line spacing, and an
|
|
99
|
+
8-by-5-inch canvas.
|
|
95
100
|
- Gray 0.54-point axes/ticks, 10-point axis-title padding in both directions,
|
|
96
|
-
2% outer margins,
|
|
101
|
+
2% outer margins, 4.14-point ticks, no grid, and an inside upper-left vertical
|
|
97
102
|
legend with a transparent background.
|
|
98
103
|
- Dots use plotnine size 3.75. Legend dots are 25% larger; connected-line plots
|
|
99
104
|
show dots in the legend when points are enabled and lines otherwise.
|
|
100
105
|
- Confidence intervals use a 0.54-point stroke. Dots and interval strokes are
|
|
101
106
|
separate layers, so changing one does not change the other.
|
|
102
107
|
|
|
108
|
+
All five functions accept `title=` for a centered plot title. Grouped bars,
|
|
109
|
+
validation plots, and intervals on categorical axes put the legend above the panel.
|
|
110
|
+
For longer category names, supply
|
|
111
|
+
labels with line breaks or use `scale_x_discrete(labels=...)` to wrap them.
|
|
112
|
+
Datetime x-axes covering less than a day show clock times in the data's time zone.
|
|
113
|
+
|
|
103
114
|
Point-based functions accept `point_size`. Those with intervals also accept
|
|
104
|
-
`ci_width` (
|
|
105
|
-
perpendicular to the interval).
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
115
|
+
`ci_width` (stroke width in points) and `cap_width` (plot-axis units
|
|
116
|
+
perpendicular to the interval). Caps default to 1% of the numeric axis span
|
|
117
|
+
or 0.12 category units on a categorical axis. A single numeric x position
|
|
118
|
+
has no default caps. On a log axis, the cap width
|
|
119
|
+
uses the transformed axis units. Set `cap_width=0` to omit caps. Grouped
|
|
120
|
+
`plot_intervals` narrows the default caps when x positions are closely spaced.
|
|
109
121
|
|
|
110
122
|
Dollar formatting is optional, rather than imposed on every outcome:
|
|
111
123
|
|
|
@@ -116,15 +128,14 @@ p += pn.scale_y_continuous(labels=dollars) # -$1K, $0, $2.5K, ...
|
|
|
116
128
|
```
|
|
117
129
|
|
|
118
130
|
`plot_validation` keeps its existing name and rectangular shape. Both axes
|
|
119
|
-
share numeric limits, but equal physical scaling is not imposed.
|
|
120
|
-
line-width defaults are unchanged.
|
|
131
|
+
share numeric limits, but equal physical scaling is not imposed.
|
|
121
132
|
|
|
122
133
|
Titles remain ordinary plotnine labels. Move the legend without changing the
|
|
123
134
|
plot constructor or shared theme:
|
|
124
135
|
|
|
125
136
|
```python
|
|
126
137
|
p += pn.labs(title="Earnings Before and After Enrollment")
|
|
127
|
-
p += pn.theme(legend_position_inside=(0.97, 0.97),
|
|
138
|
+
p += pn.theme(legend_position="inside", legend_position_inside=(0.97, 0.97),
|
|
128
139
|
legend_justification_inside=(1, 1)) # upper-right
|
|
129
140
|
```
|
|
130
141
|
|
|
@@ -148,7 +159,9 @@ p = (
|
|
|
148
159
|
|
|
149
160
|
For unconnected cohort estimates plus a supplied curve, use
|
|
150
161
|
`plot_scatter(df, x="year", y="effect", lower="lower", upper="upper", fitted=curve)`.
|
|
151
|
-
For estimates across categories
|
|
162
|
+
For estimates across categories, use `plot_intervals`. Pass `fitted=` to draw a
|
|
163
|
+
curve from supplied values. For a single series, a supplied curve appears in teal
|
|
164
|
+
with gray points in both `plot_intervals` and `plot_scatter`.
|
|
152
165
|
|
|
153
166
|
## Styling your own plots
|
|
154
167
|
|
|
@@ -200,6 +213,14 @@ python examples/paper_examples.py outputs/examples
|
|
|
200
213
|
pytest
|
|
201
214
|
```
|
|
202
215
|
|
|
216
|
+
## Changes in 0.4
|
|
217
|
+
|
|
218
|
+
- Validation plots accept `series=` to color by program or another grouping column.
|
|
219
|
+
- Plot titles are larger and centered, confidence intervals have short caps by
|
|
220
|
+
default, and interval plots accept a supplied fit.
|
|
221
|
+
- Numeric group labels, sparse groups, missing estimates, and intraday axes
|
|
222
|
+
render more reliably.
|
|
223
|
+
|
|
203
224
|
## Changes in 0.3
|
|
204
225
|
|
|
205
226
|
- Paper defaults: Times New Roman, inside vertical legends, five-point ticks,
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Workforce plots, shared paper styling, and the Opportunity Insights palette."""
|
|
2
|
+
|
|
3
|
+
from math import pi, sqrt
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import plotnine as pn
|
|
9
|
+
from matplotlib import font_manager, get_data_path
|
|
10
|
+
from mizani.labels import label_date
|
|
11
|
+
from mizani.palettes import hue_pal
|
|
12
|
+
from oi_tools.figures import OIColors as WFColors, theme_oi
|
|
13
|
+
|
|
14
|
+
PALETTE = [WFColors.TEAL, WFColors.ORANGE, WFColors.NAVY, WFColors.PURPLE,
|
|
15
|
+
WFColors.BLUE, WFColors.RED, WFColors.DARK_GREEN]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def theme_workforce(base_size=12, base_family="Times New Roman", width=8, height=5):
|
|
19
|
+
"""Apply the paper style to a plotnine plot.
|
|
20
|
+
|
|
21
|
+
base_size: Base text size in points (12).
|
|
22
|
+
base_family: Font family (Times New Roman). The theme silently selects
|
|
23
|
+
an available fallback when the preferred paper fonts are missing.
|
|
24
|
+
width, height: Figure size in inches (8 by 5).
|
|
25
|
+
|
|
26
|
+
Example
|
|
27
|
+
-------
|
|
28
|
+
>>> p = p + theme_workforce(width=10, height=6)
|
|
29
|
+
"""
|
|
30
|
+
fallback_families = ("DejaVu Serif", "DejaVu Sans", "STIXGeneral")
|
|
31
|
+
if base_family == "Times New Roman" or base_family in fallback_families:
|
|
32
|
+
available = set(font_manager.get_font_names())
|
|
33
|
+
if base_family not in available:
|
|
34
|
+
if not available:
|
|
35
|
+
bundled = Path(get_data_path()) / "fonts" / "ttf"
|
|
36
|
+
for path in bundled.glob("DejaVuSerif*.ttf"):
|
|
37
|
+
font_manager.fontManager.addfont(path)
|
|
38
|
+
available = set(font_manager.get_font_names())
|
|
39
|
+
if not available:
|
|
40
|
+
raise RuntimeError("Matplotlib requires a font to render plot text.")
|
|
41
|
+
base_family = next(
|
|
42
|
+
(family for family in fallback_families if family in available),
|
|
43
|
+
min(available),
|
|
44
|
+
)
|
|
45
|
+
return theme_oi(base_size, base_family, width, height) + pn.theme(
|
|
46
|
+
text=pn.element_text(linespacing=1.2),
|
|
47
|
+
axis_line=pn.element_line(color="#A0A0A0", size=0.54),
|
|
48
|
+
axis_ticks=pn.element_line(color="#A0A0A0", size=0.54),
|
|
49
|
+
axis_ticks_length=4.14,
|
|
50
|
+
axis_text=pn.element_text(color="black", size=base_size),
|
|
51
|
+
axis_title=pn.element_text(size=base_size),
|
|
52
|
+
axis_title_y=pn.element_text(margin={"r": 10}),
|
|
53
|
+
axis_title_x=pn.element_text(margin={"t": 10}),
|
|
54
|
+
plot_title=pn.element_text(size=base_size + 2, ha="center", margin={"b": 8}),
|
|
55
|
+
plot_title_position="plot",
|
|
56
|
+
legend_position="inside",
|
|
57
|
+
legend_position_inside=(0.03, 0.97),
|
|
58
|
+
legend_justification_inside=(0, 1),
|
|
59
|
+
legend_direction="vertical",
|
|
60
|
+
legend_background=pn.element_blank(),
|
|
61
|
+
legend_text=pn.element_text(size=base_size),
|
|
62
|
+
legend_title=pn.element_blank(),
|
|
63
|
+
plot_margin=0.02,
|
|
64
|
+
panel_grid=pn.element_blank(),
|
|
65
|
+
axis_ticks_minor=pn.element_blank(),
|
|
66
|
+
legend_key=pn.element_blank(),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def dollars(values):
|
|
71
|
+
"""Format tick values in dollars as $1K, $2.5K, and $0.
|
|
72
|
+
|
|
73
|
+
values: Dollar amounts supplied by the axis scale.
|
|
74
|
+
|
|
75
|
+
Example
|
|
76
|
+
-------
|
|
77
|
+
>>> p = p + pn.scale_y_continuous(labels=dollars)
|
|
78
|
+
"""
|
|
79
|
+
return [f"{'-' if v < 0 else ''}${abs(v) / 1000:g}K" if v else "$0" for v in values]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _data(data):
|
|
83
|
+
"""Keep pandas inputs; convert Polars using its public conversion method."""
|
|
84
|
+
if isinstance(data, pd.DataFrame):
|
|
85
|
+
return data
|
|
86
|
+
return data.to_pandas()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _interval(lower, upper):
|
|
90
|
+
if (lower is None) != (upper is None):
|
|
91
|
+
raise ValueError("Supply both lower and upper interval columns, or neither.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _group_data(data, series):
|
|
95
|
+
data = _data(data)
|
|
96
|
+
if series is not None and not _categorical(data[series]):
|
|
97
|
+
return data.assign(**{series: data[series].astype("category")})
|
|
98
|
+
return data
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _group_colors(data, series, fitted=None):
|
|
102
|
+
groups = data[series] if fitted is None else pd.concat([data[series], fitted[series]])
|
|
103
|
+
count = groups.nunique()
|
|
104
|
+
return PALETTE if count <= len(PALETTE) else hue_pal()(count)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _x_spacing(values):
|
|
108
|
+
spacing = values[np.isfinite(values)].drop_duplicates().sort_values().diff().min()
|
|
109
|
+
return spacing if pd.notna(spacing) else 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _IntervalDodge(pn.position_dodge):
|
|
113
|
+
def setup_params(self, data):
|
|
114
|
+
params = super().setup_params(data)
|
|
115
|
+
params["width"] = 0.5 * _x_spacing(data["x"])
|
|
116
|
+
return params
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _ErrorBar(pn.geom_errorbar):
|
|
120
|
+
DEFAULT_PARAMS = {**pn.geom_errorbar.DEFAULT_PARAMS, "width": None, "categorical": False}
|
|
121
|
+
|
|
122
|
+
def setup_data(self, data):
|
|
123
|
+
# Plotnine has already mapped discrete axes and transformed log axes.
|
|
124
|
+
width = self.params["width"]
|
|
125
|
+
if width is None:
|
|
126
|
+
x = data.loc[np.isfinite(data["x"]), "x"]
|
|
127
|
+
span = x.max() - x.min()
|
|
128
|
+
width = 0.01 * span if span > 0 else 0
|
|
129
|
+
if self.params["categorical"]:
|
|
130
|
+
width = 0.12
|
|
131
|
+
if isinstance(self.params["position"], _IntervalDodge):
|
|
132
|
+
width = min(width, 0.5 * _x_spacing(x))
|
|
133
|
+
data["width"] = width
|
|
134
|
+
return super().setup_data(data)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class _Bar(pn.geom_col):
|
|
138
|
+
def setup_data(self, data):
|
|
139
|
+
data["width"] = self.params["width"] * _x_spacing(data["x"])
|
|
140
|
+
return super().setup_data(data)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _categorical(values):
|
|
144
|
+
return not (pd.api.types.is_numeric_dtype(values)
|
|
145
|
+
or pd.api.types.is_datetime64_any_dtype(values))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _time_axis(plot, values):
|
|
149
|
+
if not pd.api.types.is_datetime64_any_dtype(values):
|
|
150
|
+
return plot
|
|
151
|
+
start, end = values.min(), values.max()
|
|
152
|
+
span = end - start
|
|
153
|
+
if not pd.Timedelta(0) < span < pd.Timedelta(days=1):
|
|
154
|
+
return plot
|
|
155
|
+
fmt = "%H:%M:%S" if span < pd.Timedelta(minutes=5) else "%H:%M"
|
|
156
|
+
if start.date() != end.date():
|
|
157
|
+
fmt = "%m-%d " + fmt
|
|
158
|
+
return plot + pn.scale_x_datetime(labels=label_date(fmt, tz=values.dt.tz))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def plot_lines(data, *, x, y, series=None, lower=None, upper=None, points=True,
|
|
162
|
+
point_size=3.75, ci_width=0.54, cap_width=None, title=None):
|
|
163
|
+
"""Connect points in x order within each series.
|
|
164
|
+
|
|
165
|
+
data: Pandas or Polars dataframe with one row per point.
|
|
166
|
+
x, y: Column names for the horizontal positions and values.
|
|
167
|
+
series: Optional group column for separate lines and colors.
|
|
168
|
+
lower, upper: Optional pair of y interval-bound columns.
|
|
169
|
+
points: Draw dots (True).
|
|
170
|
+
point_size: Dot size in plotnine units (3.75).
|
|
171
|
+
ci_width: Interval stroke width in points (0.54).
|
|
172
|
+
cap_width: Cap width in plot-axis units. Defaults to short caps. Use 0 for none.
|
|
173
|
+
title: Optional plot title.
|
|
174
|
+
|
|
175
|
+
Example
|
|
176
|
+
-------
|
|
177
|
+
>>> plot_lines(df, x="year", y="effect", series="group")
|
|
178
|
+
"""
|
|
179
|
+
_interval(lower, upper)
|
|
180
|
+
mapping = pn.aes(x=x, y=y, group=1)
|
|
181
|
+
if series is not None:
|
|
182
|
+
mapping.update(color=series, group=series)
|
|
183
|
+
data = _group_data(data, series)
|
|
184
|
+
p = pn.ggplot(data, mapping)
|
|
185
|
+
if lower is not None:
|
|
186
|
+
p += _ErrorBar(pn.aes(ymin=lower, ymax=upper), width=cap_width,
|
|
187
|
+
categorical=_categorical(data[x]), na_rm=True,
|
|
188
|
+
size=ci_width / sqrt(pi), show_legend=False,
|
|
189
|
+
**({} if series is not None else {"color": WFColors.TEAL}))
|
|
190
|
+
p += pn.geom_line(size=0.914, show_legend=not points,
|
|
191
|
+
**({} if series is not None else {"color": WFColors.TEAL}))
|
|
192
|
+
if points:
|
|
193
|
+
p += pn.geom_point(size=point_size, stroke=0, na_rm=True,
|
|
194
|
+
**({} if series is not None else {"color": WFColors.TEAL}))
|
|
195
|
+
if series is not None:
|
|
196
|
+
p += pn.scale_color_manual(values=_group_colors(data, series))
|
|
197
|
+
if points:
|
|
198
|
+
p += pn.guides(color=pn.guide_legend(override_aes={"size": point_size * 1.25}))
|
|
199
|
+
return _time_axis(p + theme_workforce() + pn.labs(title=title), data[x])
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def plot_validation(data, *, x, y, x_lower=None, x_upper=None,
|
|
203
|
+
y_lower=None, y_upper=None, series=None, gaps=None, limits=None,
|
|
204
|
+
point_size=3.75, ci_width=0.54, cap_width=None, title=None):
|
|
205
|
+
"""Compare paired estimates against y=x using common axis limits.
|
|
206
|
+
|
|
207
|
+
data: Pandas or Polars dataframe with one row per pair.
|
|
208
|
+
x, y: Column names for the two estimates.
|
|
209
|
+
x_lower, x_upper: Optional pair of horizontal interval-bound columns.
|
|
210
|
+
y_lower, y_upper: Optional pair of vertical interval-bound columns.
|
|
211
|
+
series: Optional group column for coloring estimates and intervals.
|
|
212
|
+
gaps: Optional "horizontal" or "vertical" segments to the identity line.
|
|
213
|
+
limits: Common (min, max) range. Defaults to the estimates and bounds.
|
|
214
|
+
point_size: Dot size in plotnine units (3.75).
|
|
215
|
+
ci_width: Interval stroke width in points (0.54).
|
|
216
|
+
cap_width: Cap width in axis units (1% of the shared range). Use 0 for none.
|
|
217
|
+
title: Optional plot title.
|
|
218
|
+
|
|
219
|
+
Example
|
|
220
|
+
-------
|
|
221
|
+
>>> plot_validation(df, x="dml_effect", y="rct_effect")
|
|
222
|
+
"""
|
|
223
|
+
_interval(x_lower, x_upper)
|
|
224
|
+
_interval(y_lower, y_upper)
|
|
225
|
+
if gaps not in (None, "vertical", "horizontal"):
|
|
226
|
+
raise ValueError("gaps must be None, 'vertical', or 'horizontal'.")
|
|
227
|
+
data = _group_data(data, series)
|
|
228
|
+
pairs = data[[x, y]].to_numpy(dtype=float, na_value=np.nan)
|
|
229
|
+
data = data.loc[np.isfinite(pairs).all(axis=1)]
|
|
230
|
+
expand = limits is None
|
|
231
|
+
if expand:
|
|
232
|
+
columns = [v for v in (x, y, x_lower, x_upper, y_lower, y_upper)
|
|
233
|
+
if v is not None]
|
|
234
|
+
values = pd.concat([data[v] for v in columns]).to_numpy(dtype=float, na_value=np.nan)
|
|
235
|
+
values = values[np.isfinite(values)]
|
|
236
|
+
if not values.size:
|
|
237
|
+
raise ValueError("Supply at least one finite pair of estimates or explicit limits.")
|
|
238
|
+
limits = (values.min(), values.max())
|
|
239
|
+
if cap_width is None:
|
|
240
|
+
cap_width = 0.01 * (limits[1] - limits[0])
|
|
241
|
+
mapping = pn.aes(x=x, y=y)
|
|
242
|
+
if series is not None:
|
|
243
|
+
mapping.update(color=series)
|
|
244
|
+
point_color = {} if series is not None else {"color": WFColors.TEAL}
|
|
245
|
+
gap_color = {} if series is not None else {"color": WFColors.ORANGE}
|
|
246
|
+
p = pn.ggplot(data, mapping) + pn.geom_abline(
|
|
247
|
+
intercept=0, slope=1, color="black", size=0.6)
|
|
248
|
+
if gaps == "vertical":
|
|
249
|
+
p += pn.geom_segment(pn.aes(xend=x, yend=x), size=0.5, alpha=0.8,
|
|
250
|
+
show_legend=False, **gap_color)
|
|
251
|
+
elif gaps == "horizontal":
|
|
252
|
+
p += pn.geom_segment(pn.aes(xend=y, yend=y), size=0.5, alpha=0.8,
|
|
253
|
+
show_legend=False, **gap_color)
|
|
254
|
+
if x_lower is not None:
|
|
255
|
+
p += pn.geom_errorbarh(pn.aes(xmin=x_lower, xmax=x_upper, height=cap_width),
|
|
256
|
+
size=ci_width / sqrt(pi), alpha=0.5, na_rm=True,
|
|
257
|
+
show_legend=False, **point_color)
|
|
258
|
+
if y_lower is not None:
|
|
259
|
+
p += pn.geom_errorbar(pn.aes(ymin=y_lower, ymax=y_upper, width=cap_width),
|
|
260
|
+
size=ci_width / sqrt(pi), alpha=0.5, na_rm=True,
|
|
261
|
+
show_legend=False, **point_color)
|
|
262
|
+
p += pn.geom_point(size=point_size, stroke=0, **point_color)
|
|
263
|
+
p = (p + pn.coord_cartesian(xlim=limits, ylim=limits, expand=expand)
|
|
264
|
+
+ theme_workforce() + pn.labs(title=title))
|
|
265
|
+
if series is not None:
|
|
266
|
+
p = (p + pn.scale_color_manual(values=_group_colors(data, series),
|
|
267
|
+
labels=lambda values: [
|
|
268
|
+
"Missing" if pd.isna(value) else str(value)
|
|
269
|
+
for value in values
|
|
270
|
+
])
|
|
271
|
+
+ pn.guides(color=pn.guide_legend(ncol=2, byrow=True,
|
|
272
|
+
override_aes={"size": point_size * 1.25}))
|
|
273
|
+
+ pn.theme(legend_position="top", legend_direction="horizontal"))
|
|
274
|
+
return p
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def plot_bars(data, *, x, y, series=None, title=None):
|
|
278
|
+
"""Draw bars from values you supply.
|
|
279
|
+
|
|
280
|
+
data: Pandas or Polars dataframe with one row per bar.
|
|
281
|
+
x: Category column. Use ordered categories to set the display order.
|
|
282
|
+
y: Numeric bar-height column.
|
|
283
|
+
series: Optional group column for side-by-side bars and colors.
|
|
284
|
+
title: Optional plot title.
|
|
285
|
+
|
|
286
|
+
Example
|
|
287
|
+
-------
|
|
288
|
+
>>> plot_bars(df, x="specification", y="rmse")
|
|
289
|
+
"""
|
|
290
|
+
mapping = pn.aes(x=x, y=y, group=1)
|
|
291
|
+
if series is not None:
|
|
292
|
+
mapping.update(fill=series, group=series)
|
|
293
|
+
data = _group_data(data, series)
|
|
294
|
+
position = "identity" if series is None else pn.position_dodge2(preserve="single", padding=0)
|
|
295
|
+
p = pn.ggplot(data, mapping) + _Bar(
|
|
296
|
+
width=0.7 if series is not None else 0.55, position=position,
|
|
297
|
+
**({} if series is not None else {"fill": WFColors.TEAL}))
|
|
298
|
+
p = p + theme_workforce() + pn.labs(title=title)
|
|
299
|
+
if series is not None:
|
|
300
|
+
p = (p + pn.scale_fill_manual(values=_group_colors(data, series))
|
|
301
|
+
+ pn.guides(fill=pn.guide_legend(ncol=2, byrow=True))
|
|
302
|
+
+ pn.theme(legend_position="top", legend_direction="horizontal"))
|
|
303
|
+
return _time_axis(p, data[x])
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def plot_scatter(data, *, x, y, series=None, fitted=None, lower=None, upper=None,
|
|
307
|
+
point_size=3.75, ci_width=0.54, cap_width=None, title=None):
|
|
308
|
+
"""Draw unconnected points with optional intervals and a supplied curve.
|
|
309
|
+
|
|
310
|
+
data: Pandas or Polars dataframe with one row per point.
|
|
311
|
+
x, y: Column names for the two plotted variables.
|
|
312
|
+
series: Optional group column for separate colors.
|
|
313
|
+
fitted: Optional dataframe of fitted values using the same x, y,
|
|
314
|
+
and series column names. The supplied values are joined in x order.
|
|
315
|
+
lower, upper: Optional pair of y interval-bound columns.
|
|
316
|
+
point_size: Dot size in plotnine units (3.75).
|
|
317
|
+
ci_width: Interval stroke width in points (0.54).
|
|
318
|
+
cap_width: Cap width in plot-axis units. Defaults to short caps. Use 0 for none.
|
|
319
|
+
title: Optional plot title.
|
|
320
|
+
|
|
321
|
+
Example
|
|
322
|
+
-------
|
|
323
|
+
>>> plot_scatter(df, x="year", y="effect", fitted=curve_df)
|
|
324
|
+
"""
|
|
325
|
+
_interval(lower, upper)
|
|
326
|
+
mapping = pn.aes(x=x, y=y, group=1)
|
|
327
|
+
if series is not None:
|
|
328
|
+
mapping.update(color=series, group=series)
|
|
329
|
+
data = _group_data(data, series)
|
|
330
|
+
fitted = None if fitted is None else _group_data(fitted, series)
|
|
331
|
+
p = pn.ggplot(data, mapping)
|
|
332
|
+
point_color = "#808080" if fitted is not None else WFColors.TEAL
|
|
333
|
+
if lower is not None:
|
|
334
|
+
p += _ErrorBar(pn.aes(ymin=lower, ymax=upper), width=cap_width,
|
|
335
|
+
categorical=_categorical(data[x]), na_rm=True,
|
|
336
|
+
size=ci_width / sqrt(pi), show_legend=False,
|
|
337
|
+
**({} if series is not None else {"color": point_color}))
|
|
338
|
+
if fitted is not None:
|
|
339
|
+
p += pn.geom_line(data=fitted, size=0.914, show_legend=False,
|
|
340
|
+
**({} if series is not None else {"color": WFColors.TEAL}))
|
|
341
|
+
p += pn.geom_point(size=point_size, stroke=0, na_rm=True,
|
|
342
|
+
**({} if series is not None else {"color": point_color}))
|
|
343
|
+
if series is not None:
|
|
344
|
+
p += pn.scale_color_manual(values=_group_colors(data, series, fitted))
|
|
345
|
+
p += pn.guides(color=pn.guide_legend(override_aes={"size": point_size * 1.25}))
|
|
346
|
+
return _time_axis(p + theme_workforce() + pn.labs(title=title), data[x])
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def plot_intervals(data, *, x, y, lower, upper, series=None, fitted=None,
|
|
350
|
+
point_size=3.75, ci_width=0.54, cap_width=None, title=None):
|
|
351
|
+
"""Draw estimates and their supplied intervals across categories.
|
|
352
|
+
|
|
353
|
+
data: Pandas or Polars dataframe with one row per estimate.
|
|
354
|
+
x, y: Category and estimate column names.
|
|
355
|
+
lower, upper: Required columns containing the interval endpoints.
|
|
356
|
+
series: Optional group column for side-by-side estimates and colors.
|
|
357
|
+
fitted: Optional dataframe of fitted values with the same x, y, and series columns.
|
|
358
|
+
point_size: Dot size in plotnine units (3.75).
|
|
359
|
+
ci_width: Interval stroke width in points (0.54).
|
|
360
|
+
cap_width: Cap width in plot-axis units. Defaults to short caps. Use 0 for none.
|
|
361
|
+
title: Optional plot title.
|
|
362
|
+
|
|
363
|
+
Example
|
|
364
|
+
-------
|
|
365
|
+
>>> plot_intervals(df, x="program", y="effect", lower="lo", upper="hi")
|
|
366
|
+
"""
|
|
367
|
+
mapping = pn.aes(x=x, y=y, group=1)
|
|
368
|
+
if series is not None:
|
|
369
|
+
mapping.update(color=series, group=series)
|
|
370
|
+
data = _group_data(data, series)
|
|
371
|
+
fitted = None if fitted is None else _group_data(fitted, series)
|
|
372
|
+
point_color = "#808080" if fitted is not None else WFColors.TEAL
|
|
373
|
+
colors = {} if series is not None else {"color": point_color}
|
|
374
|
+
categorical = _categorical(data[x])
|
|
375
|
+
p = pn.ggplot(data, mapping)
|
|
376
|
+
if fitted is not None:
|
|
377
|
+
p += pn.geom_line(data=fitted, size=0.914, show_legend=False,
|
|
378
|
+
**({} if series is not None else {"color": WFColors.TEAL}))
|
|
379
|
+
position = "identity" if series is None else _IntervalDodge(width=0.5)
|
|
380
|
+
p = (p + _ErrorBar(pn.aes(ymin=lower, ymax=upper), width=cap_width,
|
|
381
|
+
categorical=categorical, na_rm=True,
|
|
382
|
+
position=position, size=ci_width / sqrt(pi),
|
|
383
|
+
show_legend=False, **colors)
|
|
384
|
+
+ pn.geom_point(position=position, size=point_size, stroke=0,
|
|
385
|
+
**colors))
|
|
386
|
+
if series is not None:
|
|
387
|
+
p += pn.scale_color_manual(values=_group_colors(data, series, fitted))
|
|
388
|
+
p += pn.guides(color=pn.guide_legend(ncol=2 if categorical else None,
|
|
389
|
+
byrow=categorical,
|
|
390
|
+
override_aes={"size": point_size * 1.25}))
|
|
391
|
+
p = p + theme_workforce() + pn.labs(title=title)
|
|
392
|
+
if series is not None and categorical:
|
|
393
|
+
p += pn.theme(legend_position="top", legend_direction="horizontal")
|
|
394
|
+
return _time_axis(p, data[x])
|