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.
Files changed (17) hide show
  1. {workforce_plotting-0.3.0/src/workforce_plotting.egg-info → workforce_plotting-0.4.0}/PKG-INFO +38 -17
  2. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/README.md +37 -16
  3. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/pyproject.toml +1 -1
  4. workforce_plotting-0.4.0/src/workforce_plotting/figures.py +394 -0
  5. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0/src/workforce_plotting.egg-info}/PKG-INFO +38 -17
  6. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/tests/test_plots.py +161 -6
  7. workforce_plotting-0.3.0/src/workforce_plotting/figures.py +0 -443
  8. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/LICENSE +0 -0
  9. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/MANIFEST.in +0 -0
  10. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/examples/default_examples.py +0 -0
  11. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/examples/paper_examples.py +0 -0
  12. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/setup.cfg +0 -0
  13. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/src/workforce_plotting/__init__.py +0 -0
  14. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/src/workforce_plotting.egg-info/SOURCES.txt +0 -0
  15. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/src/workforce_plotting.egg-info/dependency_links.txt +0 -0
  16. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/src/workforce_plotting.egg-info/requires.txt +0 -0
  17. {workforce_plotting-0.3.0 → workforce_plotting-0.4.0}/src/workforce_plotting.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: workforce-plotting
3
- Version: 0.3.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 includes every argument, its default, the expected column names,
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. Other functions accept `series` for discrete groups. Use ordered
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; install it locally to match the paper.
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
- Titles, logos, notes, and paper-specific annotations belong in the example/caller.
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 8-by-5-inch canvas.
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, five-point ticks, no grid, and an inside upper-left vertical
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` (physical stroke width in points) and `cap_width` (data units,
129
- perpendicular to the interval). A cap width of zero omits caps. `plot_lines`
130
- defaults to 0.08; scatter, validation, and interval plots default to zero.
131
- A data-unit cap width does not imply a fixed length in points across figures.
132
- For grouped `plot_intervals`, cap widths are narrowed with the dodged groups.
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. Palette and
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 without a curve, use `plot_intervals`.
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 includes every argument, its default, the expected column names,
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. Other functions accept `series` for discrete groups. Use ordered
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; install it locally to match the paper.
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
- Titles, logos, notes, and paper-specific annotations belong in the example/caller.
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 8-by-5-inch canvas.
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, five-point ticks, no grid, and an inside upper-left vertical
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` (physical stroke width in points) and `cap_width` (data units,
105
- perpendicular to the interval). A cap width of zero omits caps. `plot_lines`
106
- defaults to 0.08; scatter, validation, and interval plots default to zero.
107
- A data-unit cap width does not imply a fixed length in points across figures.
108
- For grouped `plot_intervals`, cap widths are narrowed with the dodged groups.
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. Palette and
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 without a curve, use `plot_intervals`.
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,
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "workforce-plotting"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Simple plotnine figures with Opportunity Insights styling."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -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])