Skip to content

API reference

Every public name in depictr is documented here, grouped along the path an analysis takes: look at the data, reduce it or follow it over time, read the model, check the model, judge a classifier, set the look, and compose and save the result. The groups follow the R package's reference index in the same order, so a function can be looked up in the same place on either site.

The two indexes are cut slightly differently in the few places where the Python package's own families do not line up with the R one. Time series has no group of its own here, and those functions close the 'Multivariate, survival and time series' group, which is where the Python package has always documented them. The R index's 'Diagnostics and classification' is split in two, because the classification curves are mostly computed by scikit-learn and reached through a separate install extra. R's 'Uncertainty and power' has no counterpart either, and the posterior and power curves are documented with the other model estimates they are drawn from. What the R index calls 'Theming, accessibility and reporting' appears here as 'Theme, palette and accessibility', covering the theme, the colour-vision checks, the figure audit and the label helper, while plot composition and saving move to the last group beside the datasets and the one-figure model report stays with the diagnostics it collects.

Everything listed under a group heading is importable straight from depictr. The same plots are shown rendered, with the code that produced them, in the gallery.

Exploratory analysis

A first look at a dataset: distributions, categories, relationships, correlation, cumulative distributions, outliers and a missing-data map.

These plots are shown, with the code that produced them, on the exploring data page of the gallery.

explore_distribution

explore_distribution(data, x, group=None, kind='density', bins=30, alpha=None, legend_inside=False, title=None)

Plot the distribution of a numeric variable, optionally split by a group.

Parameters:

Name Type Description Default
data DataFrame

The data.

required
x str

Name of the numeric column to display. Any other column, a boolean one included, raises TypeError, as in the R twin.

required
group str

A grouping column mapped to colour/fill. A numeric code counts as a group, but a numeric column with more than 20 distinct values raises ValueError. Rows with a missing group are dropped with a warning, and categories without rows are left out of the plot and its palette.

None
kind ('density', 'histogram', 'both')

What to draw. Any other value raises ValueError.

"density"
bins int

Number of histogram bins.

30
alpha float

Fill transparency, useful when groups overlap. None, the default, fills a grouped density at 0.2, light enough for overlapping curves to stay distinct, and everything else at 0.6. The R twin's default is 0.6 throughout. Over a histogram (kind="both"), a grouped density is drawn as an outline whatever alpha is, and the bars take the transparency.

None
legend_inside bool

When True and a group is given, place the legend inside the top-right of the panel (a unimodal distribution leaves it empty) rather than in a right-hand margin.

False
title str

Plot title.

None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> ld = dp.lexical_decision()
>>> p = dp.explore_distribution(ld, "RT", group="condition", kind="both")
Source code in src/depictr/eda.py
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
def explore_distribution(data, x, group=None, kind="density", bins=30,
                         alpha=None, legend_inside=False, title=None):
    """Plot the distribution of a numeric variable, optionally split by a group.

    Parameters
    ----------
    data : pandas.DataFrame
        The data.
    x : str
        Name of the numeric column to display. Any other column, a boolean one
        included, raises ``TypeError``, as in the R twin.
    group : str, optional
        A grouping column mapped to colour/fill. A numeric code counts as a
        group, but a numeric column with more than 20 distinct values raises
        ``ValueError``. Rows with a missing group are dropped with a warning,
        and categories without rows are left out of the plot and its palette.
    kind : {"density", "histogram", "both"}
        What to draw. Any other value raises ``ValueError``.
    bins : int
        Number of histogram bins.
    alpha : float, optional
        Fill transparency, useful when groups overlap. ``None``, the default,
        fills a grouped density at 0.2, light enough for overlapping curves to
        stay distinct, and everything else at 0.6. The R twin's default is 0.6
        throughout. Over a histogram (``kind="both"``), a grouped density is
        drawn as an outline whatever ``alpha`` is, and the bars take the
        transparency.
    legend_inside : bool
        When ``True`` and a ``group`` is given, place the legend inside the
        top-right of the panel (a unimodal distribution leaves it empty) rather
        than in a right-hand margin.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> ld = dp.lexical_decision()
    >>> p = dp.explore_distribution(ld, "RT", group="condition", kind="both")
    """
    if x not in data.columns:
        raise KeyError(f"{x!r} is not a column of `data`.")
    # An unknown kind matched neither layer and left an empty panel, and a text
    # column was drawn as a density of its category codes. The R twin refuses
    # both, and its is.numeric() is FALSE for a logical column.
    if kind not in ("density", "histogram", "both"):
        raise ValueError("`kind` must be 'density', 'histogram' or 'both'.")
    if not is_numeric_dtype(data[x]) or is_bool_dtype(data[x]):
        raise TypeError("`x` must be a numeric variable.")
    # The grouped density's light default is kept apart from the 0.6 of every
    # other fill, so that an explicit alpha reaches it too. It was fixed at 0.2.
    density_alpha = 0.2 if alpha is None else alpha
    alpha = 0.6 if alpha is None else alpha
    if group:
        # Rows missing x are not drawn, so they are left out before the groups
        # are formed, and the levels are those of the rows drawn.
        data, _, palette = _prepare_group(data.dropna(subset=[x]), group)
    mapping = aes(x=x, fill=group, color=group) if group else aes(x=x)
    p = ggplot(data, mapping)
    if kind in {"histogram", "both"}:
        y = aes(y=after_stat("density")) if kind == "both" else None
        if group:
            # Bars are filled by the group aesthetic; "none" hides the edge so
            # overlapping (identity-positioned) bars stay readable. A bare
            # fill=None would instead make the bars transparent, not group-filled.
            p = p + geom_histogram(y, bins=bins, alpha=alpha,
                                   position="identity", color="none")
        else:
            p = p + geom_histogram(y, bins=bins, alpha=alpha,
                                   position="identity", color="white", fill=BRAND)
    if kind in {"density", "both"}:
        if group:
            # A light fill (0.2 by default) keeps overlapping curves
            # distinguishable without the muddy overlap a heavier fill makes;
            # the coloured outline carries the comparison. For "both" the
            # density fill is dropped so it does not muddy the histogram
            # beneath it.
            p = p + _geom_density_linekey(
                alpha=0 if kind == "both" else density_alpha)
        else:
            p = p + geom_density(alpha=alpha, color=BRAND, fill=BRAND)
        # The density baseline sits at 0; drop the default lower pad so the
        # curves are flush with the x-axis, keeping a little headroom on top.
        p = p + scale_y_continuous(expand=(0, 0, 0.05, 0))
    if group:
        p = p + _group_scales(palette)
        if kind in {"density", "both"}:
            # The density line is the keyed geom, so show a single legend keyed
            # by its colour: drop the redundant fill legend and make the key
            # fully opaque so it matches the curve.
            p = p + guides(
                color=guide_legend(override_aes={"alpha": 1, "size": 1.2}),
                fill=False,
            )
    y_lab = "Count" if kind == "histogram" else "Density"
    p = p + labs(x=x, y=y_lab, title=title) + theme_depictr()
    if legend_inside and group:
        p = p + _legend_inside("top right")
    return p

explore_categorical

explore_categorical(data, x, group=None, proportion=True, title=None)

Bar chart of a categorical variable, optionally grouped (dodged).

Parameters:

Name Type Description Default
data DataFrame
required
x str

The categorical column. A numeric column is accepted only when it has at most 20 distinct values, such as a 0/1 coding or a Likert item. A continuous one would be drawn as one bar per value, so it raises ValueError, as in the R twin.

required
group str

A second categorical column mapped to fill, drawn side by side. A numeric code counts as a group, but a numeric column with more than 20 distinct values raises ValueError. Rows with a missing group are dropped with a warning, and categories without rows are left out of the plot and its palette. A category that a group never takes is drawn as a bar of height zero, so every bar keeps its own slot.

None
proportion bool

Show proportions within each group rather than raw counts.

True
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> wb = dp.wellbeing_survey()
>>> p = dp.explore_categorical(wb, "education", group="region")
Source code in src/depictr/eda.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
def explore_categorical(data, x, group=None, proportion=True, title=None):
    """Bar chart of a categorical variable, optionally grouped (dodged).

    Parameters
    ----------
    data : pandas.DataFrame
    x : str
        The categorical column. A numeric column is accepted only when it has
        at most 20 distinct values, such as a 0/1 coding or a Likert item. A
        continuous one would be drawn as one bar per value, so it raises
        ``ValueError``, as in the R twin.
    group : str, optional
        A second categorical column mapped to fill, drawn side by side. A
        numeric code counts as a group, but a numeric column with more than 20
        distinct values raises ``ValueError``. Rows with a missing group are
        dropped with a warning, and categories without rows are left out of
        the plot and its palette. A category that a group never takes is drawn
        as a bar of height zero, so every bar keeps its own slot.
    proportion : bool
        Show proportions within each group rather than raw counts.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> wb = dp.wellbeing_survey()
    >>> p = dp.explore_categorical(wb, "education", group="region")
    """
    if x not in data.columns:
        raise KeyError(f"{x!r} is not a column of `data`.")
    # A continuous column would be drawn as one bar per distinct value. A
    # numeric column with a handful of values is a coding, and the R twin draws
    # it, with the threshold _prepare_group() also applies to a group.
    if is_numeric_dtype(data[x]) and not is_bool_dtype(data[x]):
        n_distinct = int(data[x].dropna().nunique())
        if n_distinct > _MAX_NUMERIC_GROUPS:
            raise ValueError(
                f"`x` ({x}) is numeric with {n_distinct} distinct values; it "
                "looks continuous, not categorical. Use "
                "`explore_distribution()` instead, or convert it to a "
                "categorical first."
            )
    if group:
        # Rows missing x are not counted, so they are left out before the
        # groups are formed, and the levels are those of the rows counted.
        data, levels, palette = _prepare_group(data.dropna(subset=[x]), group)
        counts = data.groupby([group, x], observed=True).size().rename("n")
        # A category that a group never takes has no row, and plotnine then
        # gives the other group's bar the whole width of the category, where it
        # reads as a total. Each such cell is counted as zero, as the R twin's
        # table() counts it, so every bar keeps its own slot.
        cells = pd.MultiIndex.from_product(
            [levels, counts.index.get_level_values(x).unique()],
            names=[group, x])
        counts = counts.reindex(cells, fill_value=0).reset_index()
        counts[group] = pd.Categorical(counts[group], categories=levels)
        counts[x] = counts[x].astype(data[x].dtype)
        if proportion:
            group_total = counts.groupby(group, observed=True)["n"].transform("sum")
            counts["value"] = counts["n"] / group_total
        else:
            counts["value"] = counts["n"]
        p = (ggplot(counts, aes(x=x, y="value", fill=group))
             + geom_bar(stat="identity", position="dodge")
             + _group_scales(palette, ("fill",)))
        y_lab = "Proportion" if proportion else "Count"
    else:
        p = ggplot(data, aes(x=x)) + geom_bar(fill=BRAND)
        y_lab = "Count"
    return p + labs(x=x, y=y_lab, title=title) + theme_depictr()

explore_bivariate

explore_bivariate(data, x, y, title=None)

Plot the relationship between two columns, choosing the plot from their types.

The plot is selected from the column dtypes:

  • two numeric columns give a scatter plot with a trend line (:func:scatter_trend);
  • one numeric and one categorical column give boxplots of the numeric variable across the categories, with the raw points jittered over them;
  • two categorical columns give a tile of joint counts, shaded by frequency.

Datetime and timedelta columns count as numeric, so against a numeric column they give a scatter plot with a trend, and against a categorical one boxplots of their values across the categories. A boolean column counts as categorical, as a logical vector does in the R twin. A duration is drawn as a number in the unit :func:summary_table reports it in, named in the axis title, as in stay (days). The R twin draws a difftime the same way, in its own units.

Parameters:

Name Type Description Default
data DataFrame

The data.

required
x str

Names of the two columns to relate.

required
y str

Names of the two columns to relate.

required
title str

Plot title.

None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> ld = dp.lexical_decision()
>>> p = dp.explore_bivariate(ld, "condition", "RT")
Source code in src/depictr/bivariate.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def explore_bivariate(data, x, y, title=None):
    """Plot the relationship between two columns, choosing the plot from their types.

    The plot is selected from the column dtypes:

    - two numeric columns give a scatter plot with a trend line
      (:func:`scatter_trend`);
    - one numeric and one categorical column give boxplots of the numeric
      variable across the categories, with the raw points jittered over them;
    - two categorical columns give a tile of joint counts, shaded by frequency.

    Datetime and timedelta columns count as numeric, so against a numeric
    column they give a scatter plot with a trend, and against a categorical one
    boxplots of their values across the categories. A boolean column counts as
    categorical, as a logical vector does in the R twin. A duration is drawn as a
    number in the unit :func:`summary_table` reports it in, named in the axis
    title, as in ``stay (days)``. The R twin draws a ``difftime`` the same way,
    in its own units.

    Parameters
    ----------
    data : pandas.DataFrame
        The data.
    x, y : str
        Names of the two columns to relate.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> ld = dp.lexical_decision()
    >>> p = dp.explore_bivariate(ld, "condition", "RT")
    """
    for col in (x, y):
        if col not in data.columns:
            raise KeyError(f"{col!r} is not a column of `data`.")

    # A datetime or timedelta column is not numeric to pandas, so it was taken
    # for a category, and drawing boxes over it raised a TypeError. A boolean
    # column is numeric to pandas, so it was drawn as a scatter through 0 and
    # 1, where the R twin's is.numeric() takes a logical column for a category.
    x_num = _is_continuous(data[x]) and not is_bool_dtype(data[x])
    y_num = _is_continuous(data[y]) and not is_bool_dtype(data[y])
    data, axis_title = _durations_as_numbers(data, (x, y))

    if x_num and y_num:
        return (scatter_trend(data, x, y, title=title)
                + labs(x=axis_title[x], y=axis_title[y]))

    if x_num != y_num:
        # One of each: draw the numeric variable across the categories. Putting
        # the categorical on the x axis keeps the boxes upright and readable.
        cat, num = (y, x) if x_num else (x, y)
        p = (
            ggplot(data, aes(x=cat, y=num, fill=cat))
            + geom_boxplot(alpha=0.7, outlier_alpha=0)
            + geom_jitter(position=position_jitter(width=0.2), alpha=0.3,
                          color="#4d4d4d", size=1)
            + scale_fill_depictr()
            + labs(x=cat, y=axis_title[num], title=title)
            + theme_depictr()
        )
        return p

    # Two categoricals: a frequency tile of the joint distribution.
    counts = (data.groupby([x, y], observed=True).size()
              .rename("n").reset_index())
    return (
        ggplot(counts, aes(x=x, y=y, fill="n"))
        + geom_tile(color="white")
        + scale_fill_gradientn(colors=depictr_palette(7, kind="sequential"),
                               name="Count")
        + labs(x=x, y=y, title=title)
        + theme_depictr(grid="none")
    )

scatter_trend

scatter_trend(data, x, y, group=None, method='lm', title=None)

Scatter plot with a fitted trend line, and a confidence band where the smoother gives one.

The band appears only when the smoother computes one, as "lm", "glm", "gls" and "mavg" do, and "loess" once scikit-misc is installed (plotnine raises an error for it otherwise). "lowess" and "rlm" draw the line alone, and plotnine warns that it has no interval for them.

Parameters:

Name Type Description Default
data DataFrame

The data.

required
x str

Names of the numeric columns for the horizontal and vertical axes.

required
y str

Names of the numeric columns for the horizontal and vertical axes.

required
group str

A grouping column. When given, points and a separate trend line are coloured by group in the depictr palette. A numeric code counts as a group, but a numeric column with more than 20 distinct values raises ValueError. Rows with a missing group are dropped with a warning, and categories without rows are left out of the plot and its palette.

None
method str

Smoothing method passed to :func:plotnine.geom_smooth, for example "lm" for a straight line with a band, or "lowess" for a local fit that needs no extra dependency and draws no band.

'lm'
title str

Plot title.

None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> cy = dp.crop_yield()
>>> p = dp.scatter_trend(cy, "fertiliser", "yield", group="treatment")
Source code in src/depictr/bivariate.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def scatter_trend(data, x, y, group=None, method="lm", title=None):
    """Scatter plot with a fitted trend line, and a confidence band where the smoother gives one.

    The band appears only when the smoother computes one, as ``"lm"``,
    ``"glm"``, ``"gls"`` and ``"mavg"`` do, and ``"loess"`` once scikit-misc is
    installed (plotnine raises an error for it otherwise). ``"lowess"`` and
    ``"rlm"`` draw the line alone, and plotnine warns that it has no interval
    for them.

    Parameters
    ----------
    data : pandas.DataFrame
        The data.
    x, y : str
        Names of the numeric columns for the horizontal and vertical axes.
    group : str, optional
        A grouping column. When given, points and a separate trend line are
        coloured by group in the depictr palette. A numeric code counts as a
        group, but a numeric column with more than 20 distinct values raises
        ``ValueError``. Rows with a missing group are dropped with a warning,
        and categories without rows are left out of the plot and its palette.
    method : str
        Smoothing method passed to :func:`plotnine.geom_smooth`, for example
        ``"lm"`` for a straight line with a band, or ``"lowess"`` for a local
        fit that needs no extra dependency and draws no band.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> cy = dp.crop_yield()
    >>> p = dp.scatter_trend(cy, "fertiliser", "yield", group="treatment")
    """
    for col in (x, y):
        if col not in data.columns:
            raise KeyError(f"{col!r} is not a column of `data`.")

    if group:
        # Rows missing x or y are not drawn, so they are left out before the
        # groups are formed, and the levels are those of the points drawn.
        data, _, palette = _prepare_group(data.dropna(subset=[x, y]), group)
        p = (
            ggplot(data, aes(x=x, y=y, color=group, fill=group))
            + geom_point(alpha=0.6)
            + geom_smooth(method=method, alpha=0.2)
            + _group_scales(palette)
        )
    else:
        # One series: keep it on the brand colour so a lone scatter still reads
        # as depictr without a redundant legend.
        p = (
            ggplot(data, aes(x=x, y=y))
            + geom_point(color=BRAND, alpha=0.6)
            + geom_smooth(method=method, color=BRAND, fill=BRAND, alpha=0.2)
        )
    return p + labs(x=x, y=y, title=title) + theme_depictr()

correlation_heatmap

correlation_heatmap(data, cols=None, method='pearson', show_values=True, digits=2, title=None)

Heatmap of pairwise correlations among numeric columns.

Each cell can be labelled with its correlation, drawn in black or white, whichever has the higher contrast against the cell.

Parameters:

Name Type Description Default
data DataFrame
required
cols list of str

Columns to include; defaults to all numeric columns. A named column that is not numeric or boolean, such as text or a datetime, raises TypeError, and fewer than two columns left once the zero-variance ones are dropped raise ValueError.

None
method ('pearson', 'spearman', 'kendall')

The correlation coefficient, computed by :meth:pandas.DataFrame.corr over the pairwise-complete observations, as the R twin's default use = "pairwise.complete.obs" computes it. Spearman's and Kendall's coefficients measure monotone association, which suits skewed or ordinal data. Kendall's is tau-b, as in R's cor().

"pearson"
show_values bool

Whether to label each cell with its correlation.

True
digits int

The number of decimal places in the labels.

2
title str
None

Returns:

Type Description
ggplot

Zero-variance columns are dropped with a warning, and any correlation that remains undefined is labelled n/a rather than left blank.

Examples:

>>> import depictr as dp
>>> wb = dp.wellbeing_survey()
>>> p = dp.correlation_heatmap(wb)
>>> q = dp.correlation_heatmap(wb, method="spearman", show_values=False)
Source code in src/depictr/eda.py
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
def correlation_heatmap(data, cols=None, method="pearson", show_values=True,
                        digits=2, title=None):
    """Heatmap of pairwise correlations among numeric columns.

    Each cell can be labelled with its correlation, drawn in black or white,
    whichever has the higher contrast against the cell.

    Parameters
    ----------
    data : pandas.DataFrame
    cols : list of str, optional
        Columns to include; defaults to all numeric columns. A named column
        that is not numeric or boolean, such as text or a datetime, raises
        ``TypeError``, and fewer than two columns left once the zero-variance
        ones are dropped raise ``ValueError``.
    method : {"pearson", "spearman", "kendall"}
        The correlation coefficient, computed by
        :meth:`pandas.DataFrame.corr` over the pairwise-complete
        observations, as the R twin's default ``use =
        "pairwise.complete.obs"`` computes it. Spearman's and Kendall's
        coefficients measure monotone association, which suits skewed or
        ordinal data. Kendall's is tau-b, as in R's ``cor()``.
    show_values : bool
        Whether to label each cell with its correlation.
    digits : int
        The number of decimal places in the labels.
    title : str, optional

    Returns
    -------
    plotnine.ggplot
        Zero-variance columns are dropped with a warning, and any correlation
        that remains undefined is labelled ``n/a`` rather than left blank.

    Examples
    --------
    >>> import depictr as dp
    >>> wb = dp.wellbeing_survey()
    >>> p = dp.correlation_heatmap(wb)
    >>> q = dp.correlation_heatmap(wb, method="spearman", show_values=False)
    """
    if method not in ("pearson", "spearman", "kendall"):
        raise ValueError(
            "`method` must be 'pearson', 'spearman' or 'kendall'.")
    if not isinstance(show_values, bool):
        raise TypeError("`show_values` must be True or False.")
    if (isinstance(digits, bool) or not isinstance(digits, numbers.Integral)
            or digits < 0):
        raise ValueError("`digits` must be a whole number of 0 or more.")
    num = data[cols] if cols else data.select_dtypes("number")
    # Text failed inside pandas with "could not convert string to float", and a
    # datetime was correlated as the integers that store it. The wording is the
    # R twin's, as in its explore_pairs().
    not_numeric = [c for c in num.columns if not is_numeric_dtype(num[c])]
    if not_numeric:
        raise TypeError(
            "correlation_heatmap() needs numeric columns; these are not: "
            f"{', '.join(str(c) for c in not_numeric)}."
        )
    # A correlation is undefined for a constant column, so pandas fills the
    # whole row and column with NaN, which the label formatter then rendered as
    # the literal string "nan". Drop those columns and say so.
    const = [c for c in num.columns if num[c].dropna().nunique() < 2]
    if const:
        warnings.warn(
            "correlation_heatmap(): dropping zero-variance column(s): "
            f"{', '.join(str(c) for c in const)}.",
            UserWarning,
            stacklevel=2,
        )
        num = num.drop(columns=const)
    # A single column was drawn as one tile labelled 1.00. The R twin's check
    # and message.
    if num.shape[1] < 2:
        raise ValueError("Need at least two numeric columns to compute correlations.")
    corr = num.corr(method=method)
    order = list(corr.columns)
    long = corr.reset_index().melt(id_vars="index", var_name="var2",
                                   value_name="r").rename(columns={"index": "var1"})
    long["var1"] = pd.Categorical(long["var1"], categories=order, ordered=True)
    long["var2"] = pd.Categorical(long["var2"], categories=order[::-1], ordered=True)
    fill_scale = scale_fill_gradientn(
        colors=depictr_palette(7, kind="diverging"), limits=(-1, 1),
        name="Correlation",
    )
    p = ggplot(long, aes(x="var1", y="var2", fill="r")) + geom_tile(color="white")
    if show_values:
        # Pairwise-complete overlap too small to correlate still leaves an NaN
        # cell after the constant columns have gone, so label it rather than
        # print "nan".
        long["label"] = long["r"].map(
            lambda v: "n/a" if pd.isna(v) else f"{v:.{digits}f}")
        # Each label is black or white, whichever contrasts more with its tile
        # as the fill scale paints it, the scale's grey for an undefined
        # correlation included. The r = 1 diagonal is the darkest tile on the
        # ramp, where the near-black every label used to be drawn in sat at
        # 2.4:1.
        long["label_colour"] = _legible_text_colour(
            _scale_fills(fill_scale, long["r"]))
        p = (p + geom_text(aes(label="label", color="label_colour"), size=8)
             + scale_color_identity())
    return (
        p
        + fill_scale
        + labs(x="", y="", title=title)
        + theme_depictr(grid="none")
        + theme(axis_text_x=element_text(rotation=45, ha="right"))
    )

missingness_map

missingness_map(data, sort=True, legend_inside=False, title=None)

Tile map of missing values, one column per variable and one row per record.

Variables are ordered most- to least-missing, so the worst offenders sit on the left. The percentage missing is shown in each axis label.

Parameters:

Name Type Description Default
data DataFrame
required
sort bool

Order columns by their proportion of missing values.

True
legend_inside bool

When True (and sort is on), place the legend in the top-right. Because the columns are sorted, the right-hand ones are the most complete, so a legend there sits over a solid "Present" block and hides no missing marks.

False
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> wb = dp.wellbeing_survey()
>>> p = dp.missingness_map(wb, legend_inside=True)
Source code in src/depictr/eda.py
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
def missingness_map(data, sort=True, legend_inside=False, title=None):
    """Tile map of missing values, one column per variable and one row per record.

    Variables are ordered most- to least-missing, so the worst offenders sit on
    the left. The percentage missing is shown in each axis label.

    Parameters
    ----------
    data : pandas.DataFrame
    sort : bool
        Order columns by their proportion of missing values.
    legend_inside : bool
        When ``True`` (and ``sort`` is on), place the legend in the top-right.
        Because the columns are sorted, the right-hand ones are the most complete,
        so a legend there sits over a solid "Present" block and hides no missing
        marks.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> wb = dp.wellbeing_survey()
    >>> p = dp.missingness_map(wb, legend_inside=True)
    """
    miss = data.isna()
    frac = miss.mean()
    order = list(frac.sort_values(ascending=False).index) if sort else list(data.columns)
    labels = {c: f"{c} ({frac[c] * 100:.0f}%)" for c in order}
    long = (miss[order].reset_index(names="row")
            .melt(id_vars="row", var_name="variable", value_name="missing"))
    long["status"] = long["missing"].map({True: "Missing", False: "Present"})
    long["variable"] = pd.Categorical(long["variable"].map(labels),
                                      categories=[labels[c] for c in order],
                                      ordered=True)
    overall = miss.to_numpy().mean()
    p = (
        ggplot(long, aes(x="variable", y="row", fill="status"))
        + geom_tile()
        + scale_fill_manual(values={"Present": "#d9d9d9", "Missing": ACCENT},
                            name=None)
        + labs(x="", y="Record",
               title=title or f"{overall * 100:.1f}% of all values are missing")
        + theme_depictr(grid="none")
        + theme(axis_text_x=element_text(rotation=45, ha="right"))
    )
    if legend_inside and sort:
        p = p + _legend_inside("top right")
    return p

ecdf_plot

ecdf_plot(data, x, group=None, legend_inside=False, title=None)

Empirical cumulative distribution function, one step curve per group.

The ECDF reads off the proportion of observations at or below each value, so it shows the whole distribution without the smoothing choices a density makes. Curves that sit to the right are shifted towards larger values.

Parameters:

Name Type Description Default
data DataFrame

The data.

required
x str

Name of the numeric column.

required
group str

A grouping column mapped to colour, drawn as one curve per level. A numeric code counts as a group, but a numeric column with more than 20 distinct values raises ValueError. Rows with a missing group are dropped with a warning, and categories without rows are left out of the plot and its palette.

None
legend_inside bool

When True and a group is given, place the legend in the bottom-right, which an ECDF leaves empty once it saturates.

False
title str

Plot title.

None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> ld = dp.lexical_decision()
>>> p = dp.ecdf_plot(ld, "RT", group="condition", legend_inside=True)
Source code in src/depictr/distributions_extra.py
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def ecdf_plot(data, x, group=None, legend_inside=False, title=None):
    """Empirical cumulative distribution function, one step curve per group.

    The ECDF reads off the proportion of observations at or below each value, so
    it shows the whole distribution without the smoothing choices a density makes.
    Curves that sit to the right are shifted towards larger values.

    Parameters
    ----------
    data : pandas.DataFrame
        The data.
    x : str
        Name of the numeric column.
    group : str, optional
        A grouping column mapped to colour, drawn as one curve per level. A
        numeric code counts as a group, but a numeric column with more than 20
        distinct values raises ``ValueError``. Rows with a missing group are
        dropped with a warning, and categories without rows are left out of
        the plot and its palette.
    legend_inside : bool
        When ``True`` and a ``group`` is given, place the legend in the
        bottom-right, which an ECDF leaves empty once it saturates.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> ld = dp.lexical_decision()
    >>> p = dp.ecdf_plot(ld, "RT", group="condition", legend_inside=True)
    """
    if x not in data.columns:
        raise KeyError(f"{x!r} is not a column of `data`.")
    if group:
        # Rows missing x are not drawn, so they are left out before the groups
        # are formed, and the levels are those of the rows drawn.
        data, _, palette = _prepare_group(data.dropna(subset=[x]), group)
        p = (ggplot(data, aes(x=x, color=group))
             + stat_ecdf(size=0.9)
             + _group_scales(palette, ("color",)))
    else:
        p = ggplot(data, aes(x=x)) + stat_ecdf(size=0.9, color=BRAND)
    p = p + labs(x=x, y="Cumulative proportion", title=title) + theme_depictr()
    if legend_inside and group:
        p = p + _legend_inside("bottom right")
    return p

ridgeline_plot

ridgeline_plot(data, x, group, title=None)

Overlapping densities, one ridge per group level.

Each level gets its own filled density on a shared x-axis, stacked so the ridges overlap a little. Reading top to bottom gives a quick sense of how the distribution shifts across levels. Levels are ordered by their median so the progression is easy to follow, and each keeps the colour of its place in the group's own order, as in the R twin.

The overlap is faked with :func:plotnine.facet_grid: one narrow row per level, panel spacing pulled negative so neighbouring ridges touch. It stays a single ggplot, since plotnine has no dedicated ridgeline geom.

Parameters:

Name Type Description Default
data DataFrame

The data.

required
x str

Name of the numeric column.

required
group str

The grouping column; one ridge is drawn per level. A numeric code counts as a group, but a numeric column with more than 20 distinct values raises ValueError. Rows with a missing group are dropped with a warning, and categories without rows are left out of the plot and its palette.

required
title str

Plot title.

None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> wb = dp.wellbeing_survey()
>>> p = dp.ridgeline_plot(wb, "life_satisfaction", "region")
Source code in src/depictr/distributions_extra.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def ridgeline_plot(data, x, group, title=None):
    """Overlapping densities, one ridge per group level.

    Each level gets its own filled density on a shared x-axis, stacked so the
    ridges overlap a little. Reading top to bottom gives a quick sense of how the
    distribution shifts across levels. Levels are ordered by their median so the
    progression is easy to follow, and each keeps the colour of its place in
    the group's own order, as in the R twin.

    The overlap is faked with :func:`plotnine.facet_grid`: one narrow row per
    level, panel spacing pulled negative so neighbouring ridges touch. It stays a
    single ggplot, since plotnine has no dedicated ridgeline geom.

    Parameters
    ----------
    data : pandas.DataFrame
        The data.
    x : str
        Name of the numeric column.
    group : str
        The grouping column; one ridge is drawn per level. A numeric code
        counts as a group, but a numeric column with more than 20 distinct
        values raises ``ValueError``. Rows with a missing group are dropped
        with a warning, and categories without rows are left out of the plot
        and its palette.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> wb = dp.wellbeing_survey()
    >>> p = dp.ridgeline_plot(wb, "life_satisfaction", "region")
    """
    for col in (x, group):
        if col not in data.columns:
            raise KeyError(f"{col!r} is not a column of `data`.")

    # Rows missing x are not drawn, so they are left out before the groups are
    # formed. The palette names each level's colour before the levels are
    # reordered, so a ridge's colour follows its level, not its median.
    df, _, palette = _prepare_group(data[[x, group]].dropna(subset=[x]), group)
    # Order levels by median so the ridges read as a smooth progression. The
    # facet rows go top to bottom, so reverse the order to put the largest on top.
    medians = df.groupby(group, observed=True)[x].median().sort_values()
    order = list(medians.index)[::-1]
    df[group] = pd.Categorical(df[group], categories=order, ordered=True)

    return (
        ggplot(df, aes(x=x, fill=group))
        # Each facet row holds one level, so there is nothing to stack, and
        # geom_area's default stacking made the plot many times slower to draw.
        + geom_area(stat="density", position="identity", alpha=0.8,
                    color="white", size=0.3)
        + facet_grid(f"{group} ~ .", scales="free_y")
        + _group_scales(palette, ("fill",))
        # Negative panel spacing makes neighbouring ridges overlap; the y-axis
        # within each strip carries no quantitative meaning, so drop it.
        + theme_depictr(grid="x")
        + theme(
            panel_spacing_y=-0.02,
            axis_text_y=element_blank(),
            axis_ticks_major_y=element_blank(),
            legend_position="none",
            # The default row-strip label is rotated; reading group names
            # horizontally beside each ridge is easier.
            strip_text_y=element_text(angle=0, ha="left"),
        )
        + labs(x=x, y=group, title=title)
    )

dumbbell_plot

dumbbell_plot(data, category, value, group, legend_inside=False, title=None)

Dumbbell plot pairing two group values per category.

For a group with exactly two levels, each category becomes one row with a point for each level joined by a segment. The length of the segment is the gap between the two levels, which is the comparison the plot is built to show.

Parameters:

Name Type Description Default
data DataFrame

Either one row per category-and-group combination, or raw rows that are averaged to one value per combination.

required
category str

The categorical column, one row per level on the y-axis.

required
value str

The numeric column plotted along the x-axis.

required
group str

A two-level grouping column; its levels are the two points. They are taken in a categorical's declared order, and otherwise sorted, as in the R twin. The first level is the start of each segment, orders the categories and takes the first colour of the palette.

required
legend_inside bool

When True, place the two-group legend in the top-right of the panel rather than in a right-hand margin.

False
title str

Plot title.

None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> wb = dp.wellbeing_survey()
>>> wb = wb.assign(age_group=np.where(wb["age"] < 50, "under 50", "50 or over"))
>>> p = dp.dumbbell_plot(wb, "region", "life_satisfaction", "age_group")
Source code in src/depictr/distributions_extra.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
def dumbbell_plot(data, category, value, group, legend_inside=False, title=None):
    """Dumbbell plot pairing two group values per category.

    For a group with exactly two levels, each category becomes one row with a
    point for each level joined by a segment. The length of the segment is the gap
    between the two levels, which is the comparison the plot is built to show.

    Parameters
    ----------
    data : pandas.DataFrame
        Either one row per category-and-group combination, or raw rows that are
        averaged to one value per combination.
    category : str
        The categorical column, one row per level on the y-axis.
    value : str
        The numeric column plotted along the x-axis.
    group : str
        A two-level grouping column; its levels are the two points. They are
        taken in a categorical's declared order, and otherwise sorted, as in
        the R twin. The first level is the start of each segment, orders the
        categories and takes the first colour of the palette.
    legend_inside : bool
        When ``True``, place the two-group legend in the top-right of the panel
        rather than in a right-hand margin.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> wb = dp.wellbeing_survey()
    >>> wb = wb.assign(age_group=np.where(wb["age"] < 50, "under 50", "50 or over"))
    >>> p = dp.dumbbell_plot(wb, "region", "life_satisfaction", "age_group")
    """
    for col in (category, value, group):
        if col not in data.columns:
            raise KeyError(f"{col!r} is not a column of `data`.")
    if category == group:
        raise ValueError("`category` and `group` must be different columns.")

    # The two levels are the two ends of every segment. The first of them
    # orders the categories, and each takes its colour in turn. They are taken
    # as the R twin takes them, over the rows that are drawn: a categorical's
    # declared order, and otherwise sorted. Taking them in order of first
    # appearance let the row order of the input rearrange the figure.
    drawn = data.loc[data[[category, value, group]].notna().all(axis=1), group]
    codes = (drawn.cat.remove_unused_categories()
             if isinstance(drawn.dtype, pd.CategoricalDtype)
             else drawn.astype("category"))
    levels = list(codes.cat.categories)
    if len(levels) != 2:
        raise ValueError(
            f"`dumbbell_plot` needs exactly two levels in {group!r}; "
            f"found {len(levels)}."
        )

    # Collapse to one value per category-and-group cell (a no-op if the caller
    # already passed a summary), then widen so the two points share a row.
    cell = (data.groupby([category, group], observed=True)[value]
            .mean().reset_index())
    wide = cell.pivot(index=category, columns=group, values=value)
    wide = wide.dropna(subset=levels).reset_index()

    # Order categories by the first level's value so the plot reads top to bottom.
    order = list(wide.sort_values(levels[0])[category])
    wide[category] = pd.Categorical(wide[category], categories=order, ordered=True)

    long = wide.melt(id_vars=category, value_vars=levels,
                     var_name=group, value_name=value)
    long[group] = pd.Categorical(long[group], categories=levels)

    p = (
        ggplot(wide, aes(y=category))
        + geom_segment(aes(x=levels[0], xend=levels[1],
                           yend=category), color="#9e9e9e", size=1.2)
        + geom_point(long, aes(x=value, y=category, color=group), size=3.5)
        + scale_colour_depictr()
        + labs(x=value, y=category, title=title)
        + theme_depictr(grid="x")
    )
    if legend_inside:
        p = p + _legend_inside("top right")
    return p

outlier_plot

outlier_plot(data, x, title=None)

Box plot of one variable with outliers flagged in the accent colour.

Points beyond 1.5 times the interquartile range from the nearest quartile, the usual Tukey rule the box plot's whiskers already use, are drawn on top in the accent colour. The box itself suppresses its own outlier markers so they are not drawn twice.

Parameters:

Name Type Description Default
data DataFrame

The data.

required
x str

Name of the numeric column to inspect.

required
title str

Plot title.

None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> cy = dp.crop_yield()
>>> p = dp.outlier_plot(cy, "yield")
Source code in src/depictr/distributions_extra.py
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
def outlier_plot(data, x, title=None):
    """Box plot of one variable with outliers flagged in the accent colour.

    Points beyond 1.5 times the interquartile range from the nearest quartile,
    the usual Tukey rule the box plot's whiskers already use, are drawn on top in
    the accent colour. The box itself suppresses its own outlier markers so they
    are not drawn twice.

    Parameters
    ----------
    data : pandas.DataFrame
        The data.
    x : str
        Name of the numeric column to inspect.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> cy = dp.crop_yield()
    >>> p = dp.outlier_plot(cy, "yield")
    """
    if x not in data.columns:
        raise KeyError(f"{x!r} is not a column of `data`.")

    values = data[x].dropna()
    q1, q3 = values.quantile([0.25, 0.75])
    iqr = q3 - q1
    lo, hi = q1 - 1.5 * iqr, q3 + 1.5 * iqr
    outliers = values[(values < lo) | (values > hi)]
    flagged = pd.DataFrame({x: outliers, "_y": 0.0})

    # A single box, drawn horizontally; the constant y just gives geom_boxplot a
    # group to sit on. outlier_alpha=0 hides the box's own markers.
    base = data[[x]].dropna().assign(_y=0.0)
    p = (ggplot(base, aes(x="_y", y=x))
         + geom_boxplot(width=0.4, fill=BRAND, alpha=0.25, color=BRAND,
                        outlier_alpha=0))
    if len(flagged):
        p = p + geom_point(flagged, aes(x="_y", y=x), color=ACCENT, size=2.5,
                           position="jitter")
    n = len(outliers)
    sub = f"{n} outlier{'s' if n != 1 else ''} beyond 1.5 x IQR"
    return (
        p
        + coord_flip()
        + labs(x="", y=x, title=title, subtitle=sub)
        + theme_depictr(grid="x")
        + theme(axis_text_y=element_blank(), axis_ticks_major_y=element_blank())
    )

group_comparison_plot

group_comparison_plot(data, x, group, title=None, conf_level=0.95)

Group means with confidence intervals over the raw points.

The estimate-with-uncertainty alternative to a bar chart: each group shows its raw observations (jittered), the mean, and a confidence interval for the mean. Showing the spread of the data alongside the estimate guards against reading too much into a difference in means.

Parameters:

Name Type Description Default
data DataFrame

The data.

required
x str

Name of the numeric column being compared.

required
group str

The grouping column on the x-axis. A numeric code counts as a group, but a numeric column with more than 20 distinct values raises ValueError. Rows with a missing group are dropped with a warning, and categories without rows are left out of the plot and its palette.

required
title str

Plot title.

None
conf_level float

Confidence level for the t intervals, a single number strictly between 0 and 1.

0.95

Returns:

Type Description
ggplot
Notes

Each interval is the t interval for the group's mean, as in the R twin and in the upper panel of :func:depictr.estimation_plot. Its half-width is the standard error times the 1 - (1 - conf_level) / 2 quantile of the t distribution with n - 1 degrees of freedom. It describes the precision of the mean, not the spread of the data. depictr 0.3.0 used 1.96 standard errors, a normal approximation that covers only 81% of the time at n = 3 and 88% at n = 5 for a nominal 95%.

A group with a single observation has no degrees of freedom for the interval, so only its mean is drawn and a warning names the group.

Examples:

>>> import depictr as dp
>>> ld = dp.lexical_decision()
>>> p = dp.group_comparison_plot(ld, "RT", "condition")
Source code in src/depictr/distributions_extra.py
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
def group_comparison_plot(data, x, group, title=None, conf_level=0.95):
    """Group means with confidence intervals over the raw points.

    The estimate-with-uncertainty alternative to a bar chart: each group shows its
    raw observations (jittered), the mean, and a confidence interval for the mean.
    Showing the spread of the data alongside the estimate guards against reading
    too much into a difference in means.

    Parameters
    ----------
    data : pandas.DataFrame
        The data.
    x : str
        Name of the numeric column being compared.
    group : str
        The grouping column on the x-axis. A numeric code counts as a group,
        but a numeric column with more than 20 distinct values raises
        ``ValueError``. Rows with a missing group are dropped with a warning,
        and categories without rows are left out of the plot and its palette.
    title : str, optional
        Plot title.
    conf_level : float
        Confidence level for the t intervals, a single number strictly between
        0 and 1.

    Returns
    -------
    plotnine.ggplot

    Notes
    -----
    Each interval is the t interval for the group's mean, as in the R twin and
    in the upper panel of :func:`depictr.estimation_plot`. Its half-width is the
    standard error times the ``1 - (1 - conf_level) / 2`` quantile of the t
    distribution with n - 1 degrees of freedom. It describes the precision
    of the mean, not the spread of the data. depictr 0.3.0 used 1.96 standard
    errors, a normal approximation that covers only 81% of the time at n = 3
    and 88% at n = 5 for a nominal 95%.

    A group with a single observation has no degrees of freedom for the
    interval, so only its mean is drawn and a warning names the group.

    Examples
    --------
    >>> import depictr as dp
    >>> ld = dp.lexical_decision()
    >>> p = dp.group_comparison_plot(ld, "RT", "condition")
    """
    for col in (x, group):
        if col not in data.columns:
            raise KeyError(f"{col!r} is not a column of `data`.")
    _check_conf_level(conf_level)

    from scipy.stats import t as t_dist

    # Rows missing x are not drawn, so they are left out before the groups are
    # formed, and a group without observations is no group at all.
    df, _, palette = _prepare_group(data[[x, group]].dropna(subset=[x]), group)
    stats = (df.groupby(group, observed=True)[x]
             .agg(["mean", "std", "count"]).reset_index())
    se = stats["std"] / np.sqrt(stats["count"])
    # A single observation has no degrees of freedom: its standard deviation
    # and t quantile are NaN, so its bounds are NaN and only the mean is drawn.
    df_t = (stats["count"] - 1).where(stats["count"] > 1)
    tc = t_dist.ppf(1 - (1 - conf_level) / 2, df_t)
    stats["lower"] = stats["mean"] - tc * se
    stats["upper"] = stats["mean"] + tc * se
    sparse = stats.loc[stats["count"] < 2, group]
    if len(sparse):
        warnings.warn(
            "No confidence interval drawn for group(s) with n < 2: "
            f"{', '.join(str(g) for g in sparse)}; showing the mean only.",
            UserWarning,
            stacklevel=2,
        )

    return (
        ggplot(df, aes(x=group, color=group))
        + geom_jitter(aes(y=x), width=0.12, height=0, alpha=0.25, size=1.5)
        # na_rm: the NaN bounds of a single-observation group are dropped
        # quietly, since the warning above has already named the group.
        + geom_errorbar(stats, aes(ymin="lower", ymax="upper"),
                        width=0.15, size=0.9, na_rm=True)
        + geom_point(stats, aes(y="mean"), size=3.5)
        + _group_scales(palette, ("color",))
        + labs(x=group, y=x, title=title)
        + theme_depictr()
        + theme(legend_position="none")
    )

raincloud_plot

raincloud_plot(data, x, group=None, title=None)

Raincloud plot of a numeric variable, optionally split by a group.

A raincloud sets three views of the same distribution side by side: a half-violin density (the "cloud"), a narrow boxplot for the median and quartiles, and the jittered raw points (the "rain"). Seeing the shape, the summary and every observation together guards against the boxplot hiding bimodality or a thin tail (Allen et al., 2021).

Parameters:

Name Type Description Default
data DataFrame

The data.

required
x str

Name of the numeric column whose distribution is shown.

required
group str

A categorical column. Each level gets its own raincloud along the horizontal axis, coloured by the depictr palette. With no group a single raincloud is drawn. A numeric code counts as a group, but a numeric column with more than 20 distinct values raises ValueError. Rows with a missing group are dropped with a warning, and categories without rows are left out of the plot and its palette.

None
title str

Plot title.

None

Returns:

Type Description
ggplot
References

Allen, M., Poggiali, D., Whitaker, K., Marshall, T. R., van Langen, J., & Kievit, R. A. (2021). Raincloud plots: A multi-platform tool for robust data visualization. Wellcome Open Research, 4, 63. https://doi.org/10.12688/wellcomeopenres.15191.2

Examples:

>>> import depictr as dp
>>> ld = dp.lexical_decision()
>>> p = dp.raincloud_plot(ld, "RT", group="condition")
Source code in src/depictr/eda_extra.py
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def raincloud_plot(data, x, group=None, title=None):
    """Raincloud plot of a numeric variable, optionally split by a group.

    A raincloud sets three views of the same distribution side by side: a
    half-violin density (the "cloud"), a narrow boxplot for the median and
    quartiles, and the jittered raw points (the "rain"). Seeing the shape, the
    summary and every observation together guards against the boxplot hiding
    bimodality or a thin tail (Allen et al., 2021).

    Parameters
    ----------
    data : pandas.DataFrame
        The data.
    x : str
        Name of the numeric column whose distribution is shown.
    group : str, optional
        A categorical column. Each level gets its own raincloud along the
        horizontal axis, coloured by the depictr palette. With no group a single
        raincloud is drawn. A numeric code counts as a group, but a numeric
        column with more than 20 distinct values raises ``ValueError``. Rows
        with a missing group are dropped with a warning, and categories without
        rows are left out of the plot and its palette.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    References
    ----------
    Allen, M., Poggiali, D., Whitaker, K., Marshall, T. R., van Langen, J., &
    Kievit, R. A. (2021). Raincloud plots: A multi-platform tool for robust data
    visualization. Wellcome Open Research, 4, 63.
    https://doi.org/10.12688/wellcomeopenres.15191.2

    Examples
    --------
    >>> import depictr as dp
    >>> ld = dp.lexical_decision()
    >>> p = dp.raincloud_plot(ld, "RT", group="condition")
    """
    if x not in data.columns:
        raise KeyError(f"{x!r} is not a column of `data`.")
    if group is not None and group not in data.columns:
        raise KeyError(f"{group!r} is not a column of `data`.")

    # With no group, a constant axis category gives every layer one slot to
    # share, so the half-violin, box and points line up.
    if group is None:
        data = data.assign(_all="")
        cat = "_all"
        mapping = aes(x=cat, y=x)
        violin = geom_violin(style="left", trim=True, width=1.0,
                             fill=BRAND, color=None, alpha=0.5)
        box = geom_boxplot(width=0.10, position=position_nudge(x=0.12),
                           outlier_alpha=0, alpha=0.7, color="#4d4d4d",
                           fill="white")
        points = geom_point(position=position_jitter(width=0.06, height=0),
                            color=BRAND, size=0.9, alpha=0.35)
    else:
        # Rows missing x are not drawn, so they are left out before the groups
        # are formed, and the levels are those of the rows drawn.
        data, _, palette = _prepare_group(data.dropna(subset=[x]), group)
        cat = group
        mapping = aes(x=cat, y=x, fill=group, color=group)
        # The half-violin sits on the left of each slot; the box and points are
        # nudged right so they sit beside it rather than on top.
        violin = geom_violin(style="left", trim=True, width=1.0,
                             color=None, alpha=0.5)
        box = geom_boxplot(width=0.10, position=position_nudge(x=0.12),
                           outlier_alpha=0, alpha=0.7, color="#4d4d4d")
        points = geom_point(position=position_jitter(width=0.06, height=0),
                            size=0.9, alpha=0.35)

    p = ggplot(data, mapping) + violin + box + points
    if group is not None:
        p = p + _group_scales(palette)
    x_lab = None if group is None else group
    p = p + labs(x=x_lab, y=x, title=title) + theme_depictr(grid="y")
    if group is not None:
        # The group is already named on the x-axis, so the colour legend only
        # repeats it; drop it.
        p = p + theme(legend_position="none")
    return p

explore_pairs

explore_pairs(data, cols=None, title=None)

Scatter-plot matrix over a few numeric columns.

Builds the familiar pairs grid: a scatter of every variable against every other off the diagonal, and that variable's own density on the diagonal. Each cell is a small themed plot, composed into an N x N grid. The number of columns is capped at five, beyond which the cells are too small to read.

Parameters:

Name Type Description Default
data DataFrame

The data.

required
cols list of str

Numeric columns to include, in order. Defaults to the first few numeric columns of data. More than five are trimmed to the first five.

None
title str

Accepted for API symmetry, but dropped with a warning: plotnine compositions cannot carry a figure-level title, so the matrix draws without one.

None

Returns:

Type Description
ggplot or Compose

The composed grid from :func:depictr.compose.arrange_plots.

Examples:

>>> import depictr as dp
>>> cy = dp.crop_yield()
>>> p = dp.explore_pairs(cy, cols=["rainfall", "fertiliser", "yield"])
Source code in src/depictr/eda_extra.py
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
def explore_pairs(data, cols=None, title=None):
    """Scatter-plot matrix over a few numeric columns.

    Builds the familiar pairs grid: a scatter of every variable against every
    other off the diagonal, and that variable's own density on the diagonal. Each
    cell is a small themed plot, composed into an N x N grid. The number of
    columns is capped at five, beyond which the cells are too small to read.

    Parameters
    ----------
    data : pandas.DataFrame
        The data.
    cols : list of str, optional
        Numeric columns to include, in order. Defaults to the first few numeric
        columns of ``data``. More than five are trimmed to the first five.
    title : str, optional
        Accepted for API symmetry, but dropped with a warning: plotnine
        compositions cannot carry a figure-level title, so the matrix draws
        without one.

    Returns
    -------
    plotnine.ggplot or plotnine.composition.Compose
        The composed grid from :func:`depictr.compose.arrange_plots`.

    Examples
    --------
    >>> import depictr as dp
    >>> cy = dp.crop_yield()
    >>> p = dp.explore_pairs(cy, cols=["rainfall", "fertiliser", "yield"])
    """
    if cols is None:
        cols = list(data.select_dtypes("number").columns)
    else:
        missing = [c for c in cols if c not in data.columns]
        if missing:
            raise KeyError(f"not column(s) of `data`: {missing}")
    if len(cols) < 2:
        raise ValueError("explore_pairs needs at least two numeric columns.")
    cols = cols[:_MAX_PAIRS_COLS]
    n = len(cols)

    # Axis titles and tick labels belong only on the outer edge, so the inner
    # cells carry no repeated axes and the variable names sit on the left and
    # bottom.
    panels = []
    for r, y_var in enumerate(cols):
        for c, x_var in enumerate(cols):
            on_left = c == 0
            on_bottom = r == n - 1
            if r == c:
                cell = (ggplot(data, aes(x=x_var))
                        + geom_density(fill=BRAND, color=BRAND, alpha=0.4))
            else:
                cell = (ggplot(data, aes(x=x_var, y=y_var))
                        + geom_point(color=BRAND, size=0.8, alpha=0.4))
            cell = (cell
                    + labs(x=x_var if on_bottom else "",
                           y=y_var if on_left else "")
                    + theme_depictr(base_size=9))
            edge = {}
            if not on_bottom:
                edge["axis_text_x"] = element_blank()
            if not on_left:
                edge["axis_text_y"] = element_blank()
            if edge:
                cell = cell + theme(**edge)
            panels.append(cell)

    # Untagged, as the R twin's patchwork grid is.
    return arrange_plots(*panels, ncol=n, title=title, tag_levels=None)

Multivariate, survival and time series

Principal components and clustering, Kaplan-Meier survival, and the time-series family (series, autocorrelation, decomposition and seasonal views).

These plots are shown, with the code that produced them, on the multivariate and time series page of the gallery, and the survival curve on the classification and survival page.

pca_plot

pca_plot(data, cols=None, group=None, title=None)

PCA biplot: observations on the first two components, with loading arrows.

Observations are scored on PC1 and PC2 and shown as points, optionally coloured by group. Each original variable is drawn as an arrow from the origin along its loading, labelled with the variable name just beyond the arrow head. The axis labels report the percentage of variance each component explains.

Parameters:

Name Type Description Default
data DataFrame

The data.

required
cols list of str

Numeric columns to decompose; defaults to all numeric columns.

None
group str

A column mapped to the colour of the observation points. Kept aside from the decomposition, which uses only the numeric cols.

None
title str

Plot title.

None

Returns:

Type Description
ggplot

Raises:

Type Description
KeyError

If group is not a column of data.

Notes

Each arrow is a variable's loadings on the two components, multiplied by a factor that all the arrows share, as in the R twin. The factor makes the largest coordinate of any arrow three quarters of the largest absolute score. Both axes use that one factor, as base R's biplot() uses one ratio for both, and the aspect ratio is fixed at 1. An arrow's angle on the page is therefore its angle in loading space, whatever the size of the figure. The panel's height and width follow the ranges of its two axes, so a figure of another shape leaves blank space around the panel. Because the scores are drawn as they are and the arrows as loadings, the angle between two arrows does not estimate the correlation between their variables. Arrows and labels are drawn in a neutral dark grey (#333333), which is not in the palette, so no arrow reads as part of a group.

Examples:

>>> import depictr as dp
>>> wb = dp.wellbeing_survey()
>>> p = dp.pca_plot(wb, group="region")
Source code in src/depictr/multivariate.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def pca_plot(data, cols=None, group=None, title=None):
    """PCA biplot: observations on the first two components, with loading arrows.

    Observations are scored on PC1 and PC2 and shown as points, optionally
    coloured by ``group``. Each original variable is drawn as an arrow from the
    origin along its loading, labelled with the variable name just beyond the
    arrow head. The axis labels report the percentage of variance each component
    explains.

    Parameters
    ----------
    data : pandas.DataFrame
        The data.
    cols : list of str, optional
        Numeric columns to decompose; defaults to all numeric columns.
    group : str, optional
        A column mapped to the colour of the observation points. Kept aside from
        the decomposition, which uses only the numeric ``cols``.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Raises
    ------
    KeyError
        If ``group`` is not a column of ``data``.

    Notes
    -----
    Each arrow is a variable's loadings on the two components, multiplied by a
    factor that all the arrows share, as in the R twin. The factor makes the
    largest coordinate of any arrow three quarters of the largest absolute
    score. Both axes use that one factor, as base R's ``biplot()`` uses one
    ratio for both, and the aspect ratio is fixed at 1. An arrow's angle on the
    page is therefore its angle in loading space, whatever the size of the
    figure. The panel's height and width follow the ranges of its two axes, so
    a figure of another shape leaves blank space around the panel. Because the
    scores are drawn as they are and the arrows as loadings, the angle between
    two arrows does not estimate the correlation between their variables.
    Arrows and labels are drawn in a neutral dark grey (``#333333``), which is
    not in the palette, so no arrow reads as part of a group.

    Examples
    --------
    >>> import depictr as dp
    >>> wb = dp.wellbeing_survey()
    >>> p = dp.pca_plot(wb, group="region")
    """
    _require_sklearn()
    from sklearn.decomposition import PCA

    if group is not None and group not in data.columns:
        # The wording of the R twin, which names the argument as well.
        raise KeyError(f"Column {group!r} (argument `group`) not found in the data.")

    num = _numeric_frame(data, cols)
    X = _standardise(num)
    pca = PCA(n_components=2)
    scores = pca.fit_transform(X)
    var = pca.explained_variance_ratio_ * 100

    pts = pd.DataFrame({"PC1": scores[:, 0], "PC2": scores[:, 1]})
    if group:
        # Align the kept-aside group to the complete-case rows used above.
        pts[group] = data.loc[num.index, group].to_numpy()

    # One factor for both axes, so that each arrow points along its loading. A
    # factor per axis stretches the arrows unevenly and bends every direction.
    loadings = pca.components_.T  # variables x components
    span = np.abs(scores).max()
    mult = span / np.abs(loadings).max() * 0.75
    end = loadings * mult
    # Each label continues its arrow outward by 8% of the span, clear of the
    # arrow head. An arrow of zero length has no direction and keeps its label
    # at the origin.
    length = np.hypot(end[:, 0], end[:, 1])[:, None]
    unit = np.divide(end, length, out=np.zeros_like(end), where=length > 0)
    label_at = end + unit * span * 0.08
    load = pd.DataFrame({
        "x": 0.0, "y": 0.0,
        "xend": end[:, 0], "yend": end[:, 1],
        "labx": label_at[:, 0], "laby": label_at[:, 1],
        "label": [_nice_label(c) for c in num.columns],
    })

    if group:
        p = (ggplot(pts, aes("PC1", "PC2", color=group))
             + geom_point(alpha=0.7, size=2)
             + scale_colour_depictr())
    else:
        p = ggplot(pts, aes("PC1", "PC2")) + geom_point(alpha=0.6, size=2, color=BRAND)

    return (
        p
        + geom_segment(aes(x="x", y="y", xend="xend", yend="yend"),
                       data=load, color=_LOADING_INK, size=0.7,
                       arrow=_arrow())
        + geom_text(aes(x="labx", y="laby", label="label"), data=load,
                    color=_LOADING_INK, size=9, fontweight="bold")
        # One unit of PC1 spans the same length on the page as one unit of PC2,
        # so the arrows keep their angles on a figure of any shape.
        + coord_fixed(ratio=1)
        + labs(x=f"PC1 ({var[0]:.1f}%)", y=f"PC2 ({var[1]:.1f}%)", title=title)
        + theme_depictr()
    )

scree_plot

scree_plot(data, cols=None, title=None)

Scree plot: variance explained per component, with the cumulative line.

Bars give the proportion of variance each principal component explains; an overlaid line and points give the running cumulative proportion. Both share the (0, 1) y-axis.

Parameters:

Name Type Description Default
data DataFrame
required
cols list of str

Numeric columns to decompose; defaults to all numeric columns.

None
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> wb = dp.wellbeing_survey()
>>> p = dp.scree_plot(wb)
Source code in src/depictr/multivariate.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
def scree_plot(data, cols=None, title=None):
    """Scree plot: variance explained per component, with the cumulative line.

    Bars give the proportion of variance each principal component explains; an
    overlaid line and points give the running cumulative proportion. Both share
    the (0, 1) y-axis.

    Parameters
    ----------
    data : pandas.DataFrame
    cols : list of str, optional
        Numeric columns to decompose; defaults to all numeric columns.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> wb = dp.wellbeing_survey()
    >>> p = dp.scree_plot(wb)
    """
    _require_sklearn()
    from sklearn.decomposition import PCA

    num = _numeric_frame(data, cols)
    X = _standardise(num)
    pca = PCA().fit(X)
    var = pca.explained_variance_ratio_
    df = pd.DataFrame({
        "component": np.arange(1, len(var) + 1),
        "variance": var,
        "cumulative": np.cumsum(var),
    })
    df["component"] = pd.Categorical(df["component"], ordered=True)

    return (
        ggplot(df, aes(x="component"))
        + geom_bar(aes(y="variance"), stat="identity", fill=BRAND, width=0.7)
        + geom_line(aes(y="cumulative", group=1), color=ACCENT, size=0.9)
        + geom_point(aes(y="cumulative"), color=ACCENT, size=2.4)
        + annotate("text", x=len(var), y=1.0, ha="right", va="bottom",
                   label="Cumulative", color=ACCENT, fontweight="bold")
        + scale_y_continuous(limits=(0, 1))
        + labs(x="Principal component", y="Proportion of variance", title=title)
        + theme_depictr()
    )

cluster_plot

cluster_plot(data, cols=None, k=3, title=None, clusters=None)

k-means clusters drawn on the first two principal components.

k-means is run on the standardised data in all of its dimensions, with the settings :func:silhouette_plot uses, so the two functions show the same partition for the same k, as the R twin does. The principal components serve only to display it. Points are drawn at their PC1 and PC2 scores, coloured by cluster, and each centroid is marked by a larger outlined point at the projection of its k-means centre. The axis labels report the percentage of variance each component explains.

Parameters:

Name Type Description Default
data DataFrame
required
cols list of str

Numeric columns to use; defaults to all numeric columns.

None
k int

Number of clusters. Ignored when clusters is supplied.

3
title str
None
clusters array - like

A clustering computed elsewhere, such as the labels of a hierarchical or PAM clustering. When it is given, it is drawn and k-means is not run. It needs one entry per row of data. Rows with a missing value in cols are dropped from both, and the labels are kept as they are.

None

Returns:

Type Description
ggplot

The cluster of each complete-case row is attached as p.clusters, a :class:pandas.Series on the index of data. k-means clusters are numbered from 1.

Raises:

Type Description
ValueError

If clusters does not have one entry per row of data.

Notes

The two displayed components hold only part of the variance, so k-means run on them would partition a different space from the one the silhouette widths describe. A k-means centroid is the cluster's mean in the full space, projected, which equals the mean of its members' scores. Supplied clusters are marked at the mean of their members' scores.

k-means settles in a local optimum. scikit-learn's algorithm and random starts differ from those of R's kmeans(), so on data without clear clusters the two twins can still assign some points differently for the same k.

Examples:

>>> import depictr as dp
>>> wb = dp.wellbeing_survey()
>>> p = dp.cluster_plot(wb, k=3)
>>> p.clusters.value_counts().sort_index().index.tolist()
[1, 2, 3]
Source code in src/depictr/multivariate.py
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
def cluster_plot(data, cols=None, k=3, title=None, clusters=None):
    """k-means clusters drawn on the first two principal components.

    k-means is run on the standardised data in all of its dimensions, with the
    settings :func:`silhouette_plot` uses, so the two functions show the same
    partition for the same ``k``, as the R twin does. The principal components
    serve only to display it. Points are drawn at their PC1 and PC2 scores,
    coloured by cluster, and each centroid is marked by a larger outlined
    point at the projection of its k-means centre. The axis labels report the
    percentage of variance each component explains.

    Parameters
    ----------
    data : pandas.DataFrame
    cols : list of str, optional
        Numeric columns to use; defaults to all numeric columns.
    k : int
        Number of clusters. Ignored when ``clusters`` is supplied.
    title : str, optional
    clusters : array-like, optional
        A clustering computed elsewhere, such as the labels of a hierarchical
        or PAM clustering. When it is given, it is drawn and k-means is not
        run. It needs one entry per row of ``data``. Rows with a missing value
        in ``cols`` are dropped from both, and the labels are kept as they are.

    Returns
    -------
    plotnine.ggplot
        The cluster of each complete-case row is attached as ``p.clusters``, a
        :class:`pandas.Series` on the index of ``data``. k-means clusters are
        numbered from 1.

    Raises
    ------
    ValueError
        If ``clusters`` does not have one entry per row of ``data``.

    Notes
    -----
    The two displayed components hold only part of the variance, so k-means
    run on them would partition a different space from the one the silhouette
    widths describe. A k-means centroid is the cluster's mean in the full
    space, projected, which equals the mean of its members' scores. Supplied
    clusters are marked at the mean of their members' scores.

    k-means settles in a local optimum. scikit-learn's algorithm and random
    starts differ from those of R's ``kmeans()``, so on data without clear
    clusters the two twins can still assign some points differently for the
    same ``k``.

    Examples
    --------
    >>> import depictr as dp
    >>> wb = dp.wellbeing_survey()
    >>> p = dp.cluster_plot(wb, k=3)
    >>> p.clusters.value_counts().sort_index().index.tolist()
    [1, 2, 3]
    """
    _require_sklearn()
    from sklearn.cluster import KMeans
    from sklearn.decomposition import PCA

    if clusters is not None and len(clusters) != len(data):
        # Checked against every row before the incomplete ones are dropped, so
        # labels sized to the complete rows cannot be misaligned in silence.
        # The R twin's wording.
        raise ValueError("`clusters` must have one entry per row of `data`.")

    num = _numeric_frame(data, cols)
    X = _standardise(num)
    pca = PCA(n_components=2).fit(X)
    scores = pca.transform(X)
    var = pca.explained_variance_ratio_ * 100

    if clusters is None:
        km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(X)
        labels = pd.Series(km.labels_ + 1, index=num.index, name="cluster")
        centres = pca.transform(km.cluster_centers_)
        cent = pd.DataFrame(centres, columns=["PC1", "PC2"])
        cent["cluster"] = np.arange(1, k + 1)
    else:
        # The complete-case rows by position, so a repeated index cannot
        # misalign the labels.
        kept = data[num.columns].notna().all(axis=1).to_numpy()
        labels = pd.Series(np.asarray(clusters)[kept], index=num.index, name="cluster")
        cent = (pd.DataFrame(scores, columns=["PC1", "PC2"])
                .groupby(labels.to_numpy(), sort=True).mean()
                .rename_axis("cluster").reset_index())
    levels = pd.Categorical(labels.to_numpy()).categories
    cent["cluster"] = pd.Categorical(cent["cluster"], categories=levels)

    pts = pd.DataFrame({
        "PC1": scores[:, 0], "PC2": scores[:, 1],
        "cluster": pd.Categorical(labels.to_numpy(), categories=levels),
    })

    p = (
        ggplot(pts, aes("PC1", "PC2", color="cluster"))
        + geom_point(alpha=0.7, size=2)
        + geom_point(data=cent, size=6, shape="X", color="#1a1a1a")
        + geom_point(data=cent, size=4, shape="X")
        + scale_colour_depictr(n=len(levels), name="Cluster")
        + labs(x=f"PC1 ({var[0]:.1f}%)", y=f"PC2 ({var[1]:.1f}%)", title=title)
        + theme_depictr()
    )
    p.clusters = labels
    return p

dendrogram_plot

dendrogram_plot(data, cols=None, method='ward', title=None)

Hierarchical-clustering dendrogram drawn from a scipy linkage.

The linkage is computed by scipy and the dendrogram coordinates it returns are redrawn as line segments under the depictr theme, so the tree matches the rest of the figure set. Leaves are not labelled, since the intent is to read the overall merge structure rather than individual observations.

Parameters:

Name Type Description Default
data DataFrame
required
cols list of str

Numeric columns to cluster on; defaults to all numeric columns.

None
method str

Linkage method passed to :func:scipy.cluster.hierarchy.linkage (for example "ward", "average", "complete").

'ward'
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> wb = dp.wellbeing_survey()
>>> p = dp.dendrogram_plot(wb.groupby("region", observed=True).mean(numeric_only=True))
Source code in src/depictr/multivariate.py
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
def dendrogram_plot(data, cols=None, method="ward", title=None):
    """Hierarchical-clustering dendrogram drawn from a scipy linkage.

    The linkage is computed by scipy and the dendrogram coordinates it returns
    are redrawn as line segments under the depictr theme, so the tree matches the
    rest of the figure set. Leaves are not labelled, since the intent is to read
    the overall merge structure rather than individual observations.

    Parameters
    ----------
    data : pandas.DataFrame
    cols : list of str, optional
        Numeric columns to cluster on; defaults to all numeric columns.
    method : str
        Linkage method passed to :func:`scipy.cluster.hierarchy.linkage`
        (for example ``"ward"``, ``"average"``, ``"complete"``).
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> wb = dp.wellbeing_survey()
    >>> p = dp.dendrogram_plot(wb.groupby("region", observed=True).mean(numeric_only=True))
    """
    _require_scipy()
    from scipy.cluster.hierarchy import dendrogram, linkage

    num = _numeric_frame(data, cols)
    X = _standardise(num)
    Z = linkage(X, method=method)
    # no_plot returns the drawing coordinates without touching matplotlib.
    dnd = dendrogram(Z, no_plot=True)

    # Each entry of icoord/dcoord is the four-point bracket joining one merge;
    # turn each bracket into three segments (up, across, down).
    rows = []
    # strict=True: scipy returns one icoord and one dcoord entry per merge, so a
    # length mismatch is impossible by construction and should raise rather than
    # silently drop the tail of the dendrogram.
    for xs, ys in zip(dnd["icoord"], dnd["dcoord"], strict=True):
        for i in range(3):
            rows.append({"x": xs[i], "y": ys[i],
                         "xend": xs[i + 1], "yend": ys[i + 1]})
    seg = pd.DataFrame(rows)

    return (
        ggplot(seg, aes(x="x", y="y", xend="xend", yend="yend"))
        + geom_segment(color=BRAND, size=0.6)
        + labs(x="", y="Distance", title=title)
        + theme_depictr(grid="y")
        + scale_y_continuous(expand=(0, 0, 0.05, 0))
    )

silhouette_plot

silhouette_plot(data, cols=None, k=3, title=None)

Silhouette widths per observation, grouped and ordered by cluster.

k-means is run on the standardised data and the silhouette width of each observation is drawn as a horizontal bar, sorted within its cluster and coloured by cluster. A dashed reference line marks the mean silhouette width, a quick read on how well-separated the clustering is overall.

Parameters:

Name Type Description Default
data DataFrame
required
cols list of str

Numeric columns to use; defaults to all numeric columns.

None
k int

Number of clusters.

3
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> wb = dp.wellbeing_survey()
>>> p = dp.silhouette_plot(wb, k=3)
Source code in src/depictr/multivariate.py
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
def silhouette_plot(data, cols=None, k=3, title=None):
    """Silhouette widths per observation, grouped and ordered by cluster.

    k-means is run on the standardised data and the silhouette width of each
    observation is drawn as a horizontal bar, sorted within its cluster and
    coloured by cluster. A dashed reference line marks the mean silhouette width,
    a quick read on how well-separated the clustering is overall.

    Parameters
    ----------
    data : pandas.DataFrame
    cols : list of str, optional
        Numeric columns to use; defaults to all numeric columns.
    k : int
        Number of clusters.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> wb = dp.wellbeing_survey()
    >>> p = dp.silhouette_plot(wb, k=3)
    """
    _require_sklearn()
    from sklearn.cluster import KMeans
    from sklearn.metrics import silhouette_samples

    num = _numeric_frame(data, cols)
    X = _standardise(num)
    labels = KMeans(n_clusters=k, n_init=10, random_state=0).fit_predict(X)
    widths = silhouette_samples(X, labels)

    df = pd.DataFrame({"cluster": labels + 1, "width": widths})
    # Order bars by cluster then by ascending width within cluster, and give
    # each its own row position up the axis.
    df = df.sort_values(["cluster", "width"]).reset_index(drop=True)
    df["position"] = np.arange(len(df))
    df["cluster"] = pd.Categorical(df["cluster"])
    mean_width = float(widths.mean())

    return (
        ggplot(df, aes(x="position", y="width", fill="cluster"))
        + geom_bar(stat="identity", width=1.0)
        + geom_hline(yintercept=mean_width, linetype="dashed", color="#1a1a1a")
        + annotate("text", x=0, y=mean_width, ha="left", va="bottom",
                   label=f"Mean = {mean_width:.2f}", color="#1a1a1a",
                   fontweight="bold")
        + scale_fill_depictr(n=k, name="Cluster")
        + coord_flip()
        + labs(x="Observation", y="Silhouette width", title=title)
        + theme_depictr(grid="x")
    )

survival_plot

survival_plot(time, event, group=None, conf_level=0.95, risk_table=False, legend_inside=False, title=None, x_lab='Time', y_lab='Survival probability', censor_marks=True, logrank=None, conf_type='log-log')

Kaplan-Meier survival curves, optionally by group, with confidence limits.

Parameters:

Name Type Description Default
time array - like

Follow-up times. Must be finite: a missing or infinite time raises, since dropping or imputing it is the analyst's decision.

required
event array - like

Event indicator, coded 0/1 (0 = censored, 1 = event) or with the survival::Surv() 1/2 coding (1 = censored, 2 = event), the two codings the R twin accepts. Booleans read as 0/1. The coding is judged over all observations together, and any other coding raises, a code that is not a whole number among them. An observation whose event is missing records neither an event nor a censoring, so it is dropped with a warning.

required
group array - like

Group label per observation; one curve per group, plus a log-rank test of the difference. Observations with a missing group are dropped with a warning, since they belong to no arm.

None
conf_level float or None

Confidence level of the pointwise limits drawn as dotted steps around each curve, as the R twin draws them, strictly between 0 and 1. None leaves them out.

0.95
risk_table bool

Add a number-at-risk table as a thin strip beneath the curves.

False
legend_inside bool

When True (and there are groups), place the group legend inside the panel rather than in a right-hand margin: the bottom-left for a plain curve, or the top-right when a risk table occupies the bottom strip. Either corner is one a descending survival curve leaves empty.

False
title str
None
x_lab str

Axis labels.

'Time'
y_lab str

Axis labels.

'Time'
censor_marks bool

Mark each censored observation with a "+" at the survival level current at its time.

True
logrank bool or None

Whether to print the log-rank test of the group difference as a subtitle when there are groups. None, the default, prints it and raises a FutureWarning, since a future release will make False the default, as in the R twin. The statistic and p-value are attached to the plot whenever there are groups, whatever this argument says.

None
conf_type ('log-log', 'log', 'plain')

The scale on which the limits are built from se, the Greenwood standard error of log S. "log-log", the default, gives S ** exp(ยฑz * se / log S), the interval lifelines computes. These limits stay between 0 and 1, and in small samples they cover better than the untransformed limits S ยฑ z * S * se (Borgan & Liestรธl, 1990). "plain" draws the untransformed limits, clipped to that range, and "log" draws S * exp(ยฑz * se), capped at 1, as R's survival::survfit() does by default. The limits are 1 while the estimate is 1, and where a curve falls to 0, they keep the values of the step before. The formulas and these two rules are the R twin's, so the twins draw the same limits.

"log-log"

Returns:

Type Description
ggplot

The plot carries .at_risk (a DataFrame of number-at-risk counts at round times from 0 to at most the longest follow-up, which are the time-axis breaks when risk_table=True) and, when grouped, .logrank_p and .logrank_stat. Its data hold one row per time in each group's event table, with the columns time, surv and group and, unless conf_level is None, lower and upper.

References

Borgan, ร˜., & Liestรธl, K. (1990). A note on confidence intervals and bands for the survival function based on transformations. Scandinavian Journal of Statistics, 17(1), 35-41.

Examples:

>>> import depictr as dp
>>> ct = dp.clinical_trial()
>>> p = dp.survival_plot(ct["time"], ct["event"], group=ct["arm"], logrank=True)
Source code in src/depictr/survival.py
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
def survival_plot(time, event, group=None, conf_level=0.95, risk_table=False,
                  legend_inside=False, title=None, x_lab="Time",
                  y_lab="Survival probability", censor_marks=True, logrank=None,
                  conf_type="log-log"):
    """Kaplan-Meier survival curves, optionally by group, with confidence limits.

    Parameters
    ----------
    time : array-like
        Follow-up times. Must be finite: a missing or infinite time raises,
        since dropping or imputing it is the analyst's decision.
    event : array-like
        Event indicator, coded 0/1 (0 = censored, 1 = event) or with the
        ``survival::Surv()`` 1/2 coding (1 = censored, 2 = event), the two
        codings the R twin accepts. Booleans read as 0/1. The coding is judged
        over all observations together, and any other coding raises, a code
        that is not a whole number among them. An observation whose event is
        missing records neither an event nor a censoring, so it is dropped
        with a warning.
    group : array-like, optional
        Group label per observation; one curve per group, plus a log-rank test
        of the difference. Observations with a missing group are dropped with a
        warning, since they belong to no arm.
    conf_level : float or None
        Confidence level of the pointwise limits drawn as dotted steps around
        each curve, as the R twin draws them, strictly between 0 and 1.
        ``None`` leaves them out.
    risk_table : bool
        Add a number-at-risk table as a thin strip beneath the curves.
    legend_inside : bool
        When ``True`` (and there are groups), place the group legend inside the
        panel rather than in a right-hand margin: the bottom-left for a plain
        curve, or the top-right when a risk table occupies the bottom strip.
        Either corner is one a descending survival curve leaves empty.
    title : str, optional
    x_lab, y_lab : str
        Axis labels.
    censor_marks : bool
        Mark each censored observation with a "+" at the survival level
        current at its time.
    logrank : bool or None
        Whether to print the log-rank test of the group difference as a
        subtitle when there are groups. ``None``, the default, prints it and
        raises a ``FutureWarning``, since a future release will make ``False``
        the default, as in the R twin. The statistic and p-value are attached
        to the plot whenever there are groups, whatever this argument says.
    conf_type : {"log-log", "log", "plain"}
        The scale on which the limits are built from se, the Greenwood
        standard error of log S. ``"log-log"``, the default, gives
        ``S ** exp(ยฑz * se / log S)``, the interval lifelines computes. These
        limits stay between 0 and 1, and in small samples they cover better
        than the untransformed limits ``S ยฑ z * S * se`` (Borgan & Liestรธl,
        1990). ``"plain"`` draws the untransformed limits, clipped to that
        range, and ``"log"`` draws ``S * exp(ยฑz * se)``, capped at 1, as R's
        ``survival::survfit()`` does by default. The limits are 1 while the
        estimate is 1, and where a curve falls to 0, they keep the values of
        the step before. The formulas and these two rules are the R twin's, so
        the twins draw the same limits.

    Returns
    -------
    plotnine.ggplot
        The plot carries ``.at_risk`` (a DataFrame of number-at-risk counts at
        round times from 0 to at most the longest follow-up, which are the
        time-axis breaks when ``risk_table=True``) and, when grouped,
        ``.logrank_p`` and ``.logrank_stat``. Its data hold one row per time
        in each group's event table, with the columns ``time``, ``surv`` and
        ``group`` and, unless ``conf_level`` is ``None``, ``lower`` and
        ``upper``.

    References
    ----------
    Borgan, ร˜., & Liestรธl, K. (1990). A note on confidence intervals and bands
    for the survival function based on transformations. Scandinavian Journal
    of Statistics, 17(1), 35-41.

    Examples
    --------
    >>> import depictr as dp
    >>> ct = dp.clinical_trial()
    >>> p = dp.survival_plot(ct["time"], ct["event"], group=ct["arm"], logrank=True)
    """
    _require_lifelines()
    from lifelines import KaplanMeierFitter

    if conf_type not in _CONF_TYPES:
        raise ValueError(
            "`conf_type` must be one of 'log-log', 'log' or 'plain'."
        )
    # A level outside (0, 1), such as 95 meant as a percentage, has no normal
    # quantile, and _km_limits() would carry the origin's limits of 1 through
    # the whole curve.
    _check_conf_level(conf_level, allow_none=True)

    time = np.asarray(time, dtype=float)
    # A missing or infinite follow-up time has no place on a time axis, and it
    # used to surface far downstream as an unattributed StopIteration from the
    # axis-break search. Deciding whether to drop or impute is the analyst's
    # call, not something to make silently on their behalf.
    if not np.isfinite(time).all():
        raise ValueError(
            "`time` must be finite; drop or impute missing follow-up times "
            "before plotting."
        )
    event = _event_values(event)
    groups = (np.asarray(group) if group is not None
              else np.repeat("all", len(time)))
    # A missing group belongs to no arm, so drop those rows before the levels
    # are taken: pd.unique() keeps the missing value as a level of its own,
    # `groups == lvl` then matches nothing, and the arm is drawn as a phantom
    # all-censored curve labelled "None".
    missing = pd.isna(groups)
    if missing.any():
        warnings.warn(
            f"{int(missing.sum())} observation(s) with a missing group were "
            f"dropped.",
            UserWarning,
            stacklevel=2,
        )
        keep = ~missing
        time, event, groups = time[keep], event[keep], groups[keep]
        if not len(groups):
            raise ValueError("`group` is missing for every observation.")
    # A missing event is neither an event nor a censoring, so the observation
    # cannot enter the estimate. A cast to int used to turn it into the integer
    # sentinel numpy fills a NaN with, which lifelines counted as a death. The
    # warning is the R twin's message, word for word.
    missing_event = np.isnan(event)
    if missing_event.any():
        warnings.warn(
            f"{int(missing_event.sum())} observation(s) with a missing status "
            f"were dropped.",
            UserWarning,
            stacklevel=2,
        )
        keep = ~missing_event
        time, event, groups = time[keep], event[keep], groups[keep]
        if not len(event):
            raise ValueError("`event` is missing for every observation.")
    event = _normalise_event(event)
    # One factor order for the whole figure -- a user-set categorical order if
    # there is one, otherwise first appearance -- so the colour legend and the
    # risk-table rows list the groups identically.
    if isinstance(getattr(group, "dtype", None), pd.CategoricalDtype):
        seen = set(groups.tolist())
        levels = [c for c in group.cat.categories if c in seen]
    else:
        levels = list(pd.unique(groups))

    band = conf_level is not None
    curves, censors, at_risk_rows = [], [], []
    breaks = _nice_breaks(float(np.max(time)))
    for lvl in levels:
        mask = groups == lvl
        kmf = KaplanMeierFitter()
        kmf.fit(time[mask], event[mask], label=str(lvl))
        # The survival function is reported at every time of the event table,
        # censoring times included, so the two line up row by row.
        table = kmf.event_table
        sf = kmf.survival_function_.reset_index()
        sf.columns = ["time", "surv"]
        if band:
            sf["lower"], sf["upper"] = _km_limits(
                sf["surv"], _greenwood_se(table), conf_level, conf_type)
        sf["group"] = str(lvl)
        curves.append(sf)
        # One mark per censored observation, at the survival level the curve
        # holds at its time, as the R twin draws them.
        n_censored = table["censored"].to_numpy(dtype=int)
        censors.append(pd.DataFrame({
            "time": np.repeat(sf["time"].to_numpy(), n_censored),
            "surv": np.repeat(sf["surv"].to_numpy(), n_censored),
            "group": str(lvl),
        }))
        for b in breaks:
            at_risk_rows.append({"group": str(lvl), "time": float(b),
                                 "n_at_risk": int(np.sum(time[mask] >= b))})
    order = [str(lvl) for lvl in levels]
    curve = pd.concat(curves, ignore_index=True)
    curve["group"] = pd.Categorical(curve["group"], categories=order, ordered=True)
    censor = pd.concat(censors, ignore_index=True)
    censor["group"] = pd.Categorical(censor["group"], categories=order,
                                     ordered=True)

    multi = len(levels) > 1
    if multi:
        p = ggplot(curve, aes("time", "surv", color="group"))
        colour = {}
    else:
        from .palette import BRAND
        p = ggplot(curve, aes("time", "surv"))
        colour = {"color": BRAND}
    if band:
        p = (p
             + geom_step(aes(y="lower"), linetype="dotted", size=0.4, **colour)
             + geom_step(aes(y="upper"), linetype="dotted", size=0.4, **colour))
    p = p + geom_step(size=0.9, **colour)
    if censor_marks and len(censor):
        p = p + geom_point(data=censor, shape="+", size=2, **colour)
    if multi:
        p = p + scale_colour_depictr()

    subtitle = None
    logrank_p = logrank_stat = None
    if multi:
        from lifelines.statistics import multivariate_logrank_test
        res = multivariate_logrank_test(time, groups, event)
        logrank_p, logrank_stat = res.p_value, res.test_statistic
        if logrank is None:
            warnings.warn(
                "survival_plot() adds the log-rank test as a subtitle when "
                "`logrank` is not given. A future release will make "
                "logrank=False the default, as in the R package, so pass "
                "logrank=True to keep the subtitle or logrank=False to leave "
                "it out.",
                FutureWarning, stacklevel=2,
            )
        if logrank is None or logrank:
            subtitle = (f"Log-rank ฯ‡ยฒ({len(levels) - 1}) = {logrank_stat:.1f}, "
                        f"$p$ {_apa_p(logrank_p)}")

    at_risk_df = pd.DataFrame(at_risk_rows)
    tmax = float(np.max(time))

    if not risk_table:
        p = (p
             + scale_y_continuous(limits=(0, 1))
             + scale_x_continuous(limits=(0, tmax))
             + labs(x=x_lab, y=y_lab, title=title, subtitle=subtitle)
             + theme_depictr())
        # A monotone-decreasing curve leaves the bottom-left empty (early times
        # are still near survival 1), so the legend can sit there.
        if legend_inside and multi:
            p = p + _legend_inside("bottom left")
        p.at_risk = at_risk_df
        p.logrank_p, p.logrank_stat = logrank_p, logrank_stat
        return p

    # Number-at-risk table as a thin strip below the y = 0 axis, in the same
    # panel as the curves so it shares the time axis. The group names label the
    # rows on the y-axis -- so the curves keep the full width with no left-hand
    # gutter -- and the counts are coloured to match the curves.
    row_h, header = 0.05, 0.05
    y_of = {g: -(header + row_h * (i + 0.5)) for i, g in enumerate(order)}
    tbl = at_risk_df.copy()
    tbl["group"] = tbl["group"].astype(str)
    tbl["y"] = tbl["group"].map(y_of)
    ymin = -(header + row_h * len(order) + 0.02)
    # The ticks sit at the exact break times the counts were taken at, and only
    # their labels are formatted. Rounding the times themselves collapsed short
    # follow-up onto one tick and put the count at 0.25 under a "0.2".
    break_labels = [f"{b:g}" for b in breaks]
    # Survival-axis ticks plus one tick per table row, labelled with the group.
    surv_breaks = [0.0, 0.25, 0.5, 0.75, 1.0]
    y_breaks = surv_breaks + [y_of[g] for g in order]
    y_labels = [f"{b:.2f}" for b in surv_breaks] + list(order)

    p = (
        p
        + geom_hline(yintercept=0, color="#cccccc", size=0.4)
        + geom_text(aes(x="time", y="y", label="n_at_risk", color="group"),
                    data=tbl, size=8, show_legend=False, inherit_aes=False)
        + annotate("text", x=0, y=-header * 0.5, label="Number at risk",
                   ha="left", fontweight="bold", color="#1a1a1a", size=9)
    )
    if not multi:
        # The single curve above has no colour aesthetic (a plain BRAND line),
        # but the risk-table text always maps color="group", so it still needs
        # a scale. When multi, the curve already added one at the top.
        p = p + scale_colour_depictr()
    p = (
        p
        + scale_y_continuous(breaks=y_breaks, labels=y_labels, limits=(ymin, 1.0))
        + scale_x_continuous(limits=(0, tmax), breaks=breaks.tolist(),
                             labels=break_labels)
        + labs(x=x_lab, y=y_lab, title=title, subtitle=subtitle)
        + theme_depictr(grid="y")
    )
    # Descending KM curves leave the panel's top-right empty and the risk table
    # owns the bottom strip, so an inside legend there uses the full width
    # rather than stranding it in a right-hand margin.
    if legend_inside and multi:
        p = p + _legend_inside("top right")
    p.at_risk = at_risk_df
    p.logrank_p, p.logrank_stat = logrank_p, logrank_stat
    return p

acf_plot

acf_plot(x, kind='acf', lags=None, title=None)

Autocorrelation or partial autocorrelation as a stem plot.

The correlations come from statsmodels (acf/pacf). The approximate 95% significance band, plus or minus 1.96 / sqrt(n), is drawn as a shaded ribbon: stems reaching past it are the candidate lags.

The partial autocorrelations are the Yule-Walker estimates from the sample autocorrelations, without the small-sample adjustment (pacf(method="ywm")). They are the values R's pacf() gives, so the twins draw the same stems. statsmodels' default, the adjusted estimator, differs from R by up to 0.18 on R's AirPassengers, and statsmodels' documentation reports that it performed consistently worse than the other estimators in simulations.

Parameters:

Name Type Description Default
x Series or array - like

The series. Any index is ignored here; only the values are used. Missing values at either end are trimmed; an internal missing value is an error, since dropping it would correlate values across the closed-up gap. The R twin keeps such a value in place and computes the correlations from the pairs of observations both present, with a warning. A frame or 2-D array with one column is read as that column, and one with several columns is refused.

required
kind ('acf', 'pacf')

Autocorrelation or partial autocorrelation.

"acf"
lags int

Number of lags to show. Defaults to min(10 * log10(n), n - 1) for the ACF and a smaller cap for the PACF, which is undefined beyond n // 2.

None
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> t = np.arange(120)
>>> series = pd.Series(50 + 0.3 * t + rng.normal(0, 3, 120))
>>> p = dp.acf_plot(series)
Source code in src/depictr/timeseries.py
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
def acf_plot(x, kind="acf", lags=None, title=None):
    """Autocorrelation or partial autocorrelation as a stem plot.

    The correlations come from statsmodels (``acf``/``pacf``). The approximate
    95% significance band, plus or minus 1.96 / sqrt(n), is drawn as a shaded
    ribbon: stems reaching past it are the candidate lags.

    The partial autocorrelations are the Yule-Walker estimates from the sample
    autocorrelations, without the small-sample adjustment
    (``pacf(method="ywm")``). They are the values R's ``pacf()`` gives, so the
    twins draw the same stems. statsmodels' default, the adjusted estimator,
    differs from R by up to 0.18 on R's ``AirPassengers``, and statsmodels'
    documentation reports that it performed consistently worse than the other
    estimators in simulations.

    Parameters
    ----------
    x : pandas.Series or array-like
        The series. Any index is ignored here; only the values are used.
        Missing values at either end are trimmed; an internal missing value is
        an error, since dropping it would correlate values across the
        closed-up gap. The R twin keeps such a value in place and computes the
        correlations from the pairs of observations both present, with a
        warning. A frame or 2-D array with one column is read as that column,
        and one with several columns is refused.
    kind : {"acf", "pacf"}
        Autocorrelation or partial autocorrelation.
    lags : int, optional
        Number of lags to show. Defaults to ``min(10 * log10(n), n - 1)`` for the
        ACF and a smaller cap for the PACF, which is undefined beyond ``n // 2``.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> import pandas as pd
    >>> rng = np.random.default_rng(0)
    >>> t = np.arange(120)
    >>> series = pd.Series(50 + 0.3 * t + rng.normal(0, 3, 120))
    >>> p = dp.acf_plot(series)
    """
    _require_statsmodels()
    from statsmodels.tsa.stattools import acf, pacf

    if kind not in {"acf", "pacf"}:
        raise ValueError("`kind` must be 'acf' or 'pacf'.")
    series = _as_complete_series(x)
    n = len(series)
    if n == 0:
        # The default lag count below takes log10(n), which is -inf at zero and
        # then an OverflowError from int(), naming neither the argument nor the
        # problem. The sibling plots turn an all-missing series away by name, so
        # this one does too.
        raise ValueError("`x` has no non-missing values to plot.")
    if lags is None:
        lags = int(min(10 * np.log10(n), n - 1))
        if kind == "pacf":
            lags = int(min(lags, n // 2 - 1))
    lags = max(int(lags), 1)

    if kind == "acf":
        values = acf(series.to_numpy(), nlags=lags, fft=True)
        y_lab = "Autocorrelation"
    else:
        values = pacf(series.to_numpy(), nlags=lags, method="ywm")
        y_lab = "Partial autocorrelation"

    # Drop lag 0 (always 1) so the band and the scale are not dominated by it.
    df = pd.DataFrame({"lag": np.arange(len(values)), "value": values})
    df = df[df["lag"] > 0]
    band = 1.96 / np.sqrt(n)

    return (
        ggplot(df, aes("lag", "value"))
        + geom_ribbon(aes(ymin=-band, ymax=band), fill=BRAND, alpha=0.12)
        + geom_hline(yintercept=0, color="#9e9e9e", size=0.4)
        + geom_segment(aes(xend="lag", yend=0), color=BRAND, size=0.7)
        + geom_point(color=BRAND, size=2)
        + labs(x="Lag", y=y_lab, title=title)
        + theme_depictr()
    )

decompose_plot

decompose_plot(x, period=None, model='additive', title=None)

Seasonal decomposition as a stacked, facetted figure.

statsmodels' seasonal_decompose splits the series into observed, trend, seasonal and residual components. The four are drawn in one figure, stacked in a single column with a free y-scale per component (facet_wrap on the component), so each is legible at its own magnitude.

Parameters:

Name Type Description Default
x Series or array - like

The series. A datetime or period index sets the x-axis; otherwise the observation number is used. Missing values at either end are trimmed; an internal missing value is an error, since dropping it would shift every later observation and misalign the components. A frame or 2-D array with one column is read as that column, and one with several columns is refused.

required
period int

Seasonal period. Inferred from a monthly, quarterly or daily index when omitted (12, 4 and 7 respectively; a daily index is assumed weekly).

None
model ('additive', 'multiplicative')

The decomposition model.

"additive"
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> t = np.arange(120)
>>> series = pd.Series(
...     50 + 0.3 * t + 10 * np.sin(2 * np.pi * t / 12) + rng.normal(0, 3, 120),
...     index=pd.period_range("2016-01", periods=120, freq="M"),
... )
>>> p = dp.decompose_plot(series, period=12)
Source code in src/depictr/timeseries.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
def decompose_plot(x, period=None, model="additive", title=None):
    """Seasonal decomposition as a stacked, facetted figure.

    statsmodels' ``seasonal_decompose`` splits the series into observed, trend,
    seasonal and residual components. The four are drawn in one figure, stacked
    in a single column with a free y-scale per component (``facet_wrap`` on the
    component), so each is legible at its own magnitude.

    Parameters
    ----------
    x : pandas.Series or array-like
        The series. A datetime or period index sets the x-axis; otherwise the
        observation number is used. Missing values at either end are trimmed;
        an internal missing value is an error, since dropping it would shift
        every later observation and misalign the components. A frame or 2-D
        array with one column is read as that column, and one with several
        columns is refused.
    period : int, optional
        Seasonal period. Inferred from a monthly, quarterly or daily index when
        omitted (12, 4 and 7 respectively; a daily index is assumed weekly).
    model : {"additive", "multiplicative"}
        The decomposition model.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> import pandas as pd
    >>> rng = np.random.default_rng(0)
    >>> t = np.arange(120)
    >>> series = pd.Series(
    ...     50 + 0.3 * t + 10 * np.sin(2 * np.pi * t / 12) + rng.normal(0, 3, 120),
    ...     index=pd.period_range("2016-01", periods=120, freq="M"),
    ... )
    >>> p = dp.decompose_plot(series, period=12)
    """
    _require_statsmodels()
    from statsmodels.tsa.seasonal import seasonal_decompose

    if model not in {"additive", "multiplicative"}:
        raise ValueError("`model` must be 'additive' or 'multiplicative'.")
    series = _as_complete_series(x)
    period = _infer_period(series, period)

    result = seasonal_decompose(series.to_numpy(), model=model, period=period)
    pieces = {
        "Observed": result.observed,
        "Trend": result.trend,
        "Seasonal": result.seasonal,
        "Residual": result.resid,
    }
    # A shared x-axis: the original index if it plots, else the position.
    idx = series.index
    x_vals = idx.to_timestamp() if isinstance(idx, pd.PeriodIndex) else idx
    if not np.issubdtype(np.asarray(x_vals).dtype, np.datetime64):
        x_vals = np.arange(len(series))

    order = list(pieces)
    long = pd.concat(
        [pd.DataFrame({"x": x_vals, "value": comp, "component": name})
         for name, comp in pieces.items()],
        ignore_index=True,
    )
    long["component"] = pd.Categorical(long["component"], categories=order,
                                       ordered=True)

    return (
        ggplot(long, aes("x", "value"))
        + geom_line(color=BRAND, size=0.7)
        + facet_wrap("component", ncol=1, scales="free_y")
        + labs(x="", y="", title=title)
        + theme_depictr()
    )

seasonal_plot

seasonal_plot(x, period=None, title=None)

Seasonal plot (one line per cycle): value by position in the period.

Each cycle (for example each year of monthly data) is one line across the positions in the period. A cycle that the series starts or ends partway through is drawn as the short line it is. Overlaying the cycles makes the repeating shape and any drift between cycles easy to read.

A monthly, quarterly or daily index carries the calendar. When its frequency implies the period, the position is read from it: the month for 12, labelled Jan to Dec, the quarter for 4, labelled Q1 to Q4, and the weekday for 7, labelled Mon to Sun. A series that starts in April then draws April at position 4, and each cycle runs from January to December, as in the R twin. A quarterly PeriodIndex with a fiscal year numbers its quarters from that year's start, as pandas does. Without such an index, as for a bare array, the positions are counted from the first observation and labelled by number.

The lines run from a mid blue for the first cycle to a dark blue for the last, so they read in time order. Even the palest has a contrast of 3.51:1 against white. Past about six cycles, neighbouring cycles take colours too close to tell apart, and the colours then show the order of the cycles more than which is which. When individual cycles must be told apart, plot fewer cycles, or use the R twin's style = "subseries". It gives each season a panel, in which every cycle keeps its own place on the axis.

Parameters:

Name Type Description Default
x Series or array - like

The series. Missing values at either end are trimmed; an internal missing value is an error, since dropping it would assign every later observation to the wrong position in the period. A frame or 2-D array with one column is read as that column, and one with several columns is refused.

required
period int

Seasonal period. Inferred from a monthly, quarterly or daily index when omitted (12, 4 and 7 respectively; a daily index is assumed weekly). A period that is not a whole number, such as 365.25 / 7 for weekly data, is rounded down to whole seasons with a UserWarning, as the R twin rounds a frequency down. A period below 2 is refused, as in the R twin.

None
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> t = np.arange(120)
>>> series = pd.Series(
...     50 + 0.3 * t + 10 * np.sin(2 * np.pi * t / 12) + rng.normal(0, 3, 120),
...     index=pd.period_range("2016-01", periods=120, freq="M"),
... )
>>> p = dp.seasonal_plot(series, period=12)
Source code in src/depictr/timeseries.py
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
def seasonal_plot(x, period=None, title=None):
    """Seasonal plot (one line per cycle): value by position in the period.

    Each cycle (for example each year of monthly data) is one line across the
    positions in the period. A cycle that the series starts or ends partway
    through is drawn as the short line it is. Overlaying the cycles makes the
    repeating shape and any drift between cycles easy to read.

    A monthly, quarterly or daily index carries the calendar. When its
    frequency implies the period, the position is read from it: the month for
    12, labelled Jan to Dec, the quarter for 4, labelled Q1 to Q4, and the
    weekday for 7, labelled Mon to Sun. A series that starts in April then
    draws April at position 4, and each cycle runs from January to December,
    as in the R twin. A quarterly ``PeriodIndex`` with a fiscal year numbers
    its quarters from that year's start, as pandas does. Without such an
    index, as for a bare array, the positions are counted from the first
    observation and labelled by number.

    The lines run from a mid blue for the first cycle to a dark blue for the
    last, so they read in time order. Even the palest has a contrast of 3.51:1
    against white. Past about six cycles, neighbouring cycles take colours too
    close to tell apart, and the colours then show the order of the cycles more
    than which is which. When individual cycles must be told apart, plot fewer
    cycles, or use the R twin's ``style = "subseries"``. It gives each season a
    panel, in which every cycle keeps its own place on the axis.

    Parameters
    ----------
    x : pandas.Series or array-like
        The series. Missing values at either end are trimmed; an internal
        missing value is an error, since dropping it would assign every later
        observation to the wrong position in the period. A frame or 2-D array
        with one column is read as that column, and one with several columns is
        refused.
    period : int, optional
        Seasonal period. Inferred from a monthly, quarterly or daily index when
        omitted (12, 4 and 7 respectively; a daily index is assumed weekly). A
        period that is not a whole number, such as 365.25 / 7 for weekly data,
        is rounded down to whole seasons with a ``UserWarning``, as the R twin
        rounds a frequency down. A period below 2 is refused, as in the R twin.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> import pandas as pd
    >>> rng = np.random.default_rng(0)
    >>> t = np.arange(120)
    >>> series = pd.Series(
    ...     50 + 0.3 * t + 10 * np.sin(2 * np.pi * t / 12) + rng.normal(0, 3, 120),
    ...     index=pd.period_range("2016-01", periods=120, freq="M"),
    ... )
    >>> p = dp.seasonal_plot(series, period=12)
    """
    series = _as_complete_series(x)
    period = _infer_period(series, _whole_period(period))

    values = series.to_numpy()
    if len(values) == 0:
        raise ValueError("`x` has no non-missing values to plot.")
    position, cycle, names = _seasons(series, period)
    n_cycles = int(cycle.max())
    df = pd.DataFrame({
        "position": position,
        "value": values,
        # Ordered on the cycle number, not on its string form: a plain
        # categorical over strings sorts lexicographically, which puts cycle 10
        # between 1 and 2 and hands it the third colour of a ramp meant to run
        # in time order.
        "cycle": pd.Categorical(
            cycle.astype(str),
            categories=[str(c) for c in range(1, n_cycles + 1)],
            ordered=True,
        ),
    })
    breaks = list(range(1, period + 1))
    x_scale = (scale_x_continuous(breaks=breaks) if names is None
               else scale_x_continuous(breaks=breaks, labels=names))

    return (
        ggplot(df, aes("position", "value", color="cycle", group="cycle"))
        + geom_line(size=0.7)
        + geom_point(size=1.5)
        + x_scale
        # The cycles are a sequence, not unrelated categories, so they take a
        # single-hue ramp. It reads in time order and, unlike the eight
        # Okabe-Ito colours of the qualitative palette, extends to any number
        # of cycles, although past about six its neighbouring colours blur. It
        # leaves out the palest colour of the sequential palette, which is
        # close to invisible as a line. The legend is reversed so the darkest,
        # most recent cycle sits at the top of the key, matching the R twin.
        + scale_color_manual(values=_cycle_palette(n_cycles),
                             name="Cycle", guide=guide_legend(reverse=True))
        + labs(x="Position in period", y="Value", title=title)
        + theme_depictr()
    )

timeseries_plot

timeseries_plot(x, rolling=None, title=None)

The series as a line, optionally with a rolling-mean overlay.

Parameters:

Name Type Description Default
x Series or array - like

The series. A datetime or period index sets the x-axis; otherwise the observation number is used. A missing value keeps its place on the axis and leaves a break in the line. A frame or 2-D array with one column is read as that column, and one with several columns is refused. The R twin draws one line per column of a multivariate ts.

required
rolling int

Window length for a centred moving average, drawn over the series in the accent colour. An odd window averages each observation with the same number of neighbours on either side. An even window, such as 12 for monthly data, gives the 2ร—12 moving average, a 12-point average followed by a 2-point one (Hyndman & Athanasopoulos, 2021, Section 3.3), as in the R twin. The two observations at the ends of the 13 it spans count half, which centres the average on its observation, as equal weights over an even window cannot. A window that runs off either end of the series, or reaches a missing value, is missing too, so the overlay breaks where the line does. Omit for the raw line only.

None
title str
None

Returns:

Type Description
ggplot
References

Hyndman, R. J., & Athanasopoulos, G. (2021). Forecasting: Principles and practice (3rd ed.). OTexts. https://otexts.com/fpp3/

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> t = np.arange(120)
>>> series = pd.Series(50 + 0.3 * t + rng.normal(0, 3, 120))
>>> p = dp.timeseries_plot(series, rolling=12)
Source code in src/depictr/timeseries.py
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
def timeseries_plot(x, rolling=None, title=None):
    """The series as a line, optionally with a rolling-mean overlay.

    Parameters
    ----------
    x : pandas.Series or array-like
        The series. A datetime or period index sets the x-axis; otherwise the
        observation number is used. A missing value keeps its place on the axis
        and leaves a break in the line. A frame or 2-D array with one column is
        read as that column, and one with several columns is refused. The R
        twin draws one line per column of a multivariate ``ts``.
    rolling : int, optional
        Window length for a centred moving average, drawn over the series in
        the accent colour. An odd window averages each observation with the
        same number of neighbours on either side. An even window, such as 12
        for monthly data, gives the 2ร—12 moving average, a 12-point average
        followed by a 2-point one (Hyndman & Athanasopoulos, 2021, Section
        3.3), as in the R twin. The two observations at the ends of the 13 it
        spans count half, which centres the average on its observation, as
        equal weights over an even window cannot. A window
        that runs off either end of the series, or reaches a missing value, is
        missing too, so the overlay breaks where the line does. Omit for the
        raw line only.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    References
    ----------
    Hyndman, R. J., & Athanasopoulos, G. (2021). Forecasting: Principles and
    practice (3rd ed.). OTexts. https://otexts.com/fpp3/

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> import pandas as pd
    >>> rng = np.random.default_rng(0)
    >>> t = np.arange(120)
    >>> series = pd.Series(50 + 0.3 * t + rng.normal(0, 3, 120))
    >>> p = dp.timeseries_plot(series, rolling=12)
    """
    series = _as_series(x)
    idx = series.index
    x_vals = idx.to_timestamp() if isinstance(idx, pd.PeriodIndex) else idx
    if not np.issubdtype(np.asarray(x_vals).dtype, np.datetime64):
        x_vals = np.arange(len(series))
    df = pd.DataFrame({"x": x_vals, "value": series.to_numpy()})

    # plotnine keeps a missing value inside a line as a break in it, which is
    # how a gap should look. na_rm silences the warning about the missing values
    # it drops at either end, which every rolling overlay has.
    p = ggplot(df, aes("x", "value")) + geom_line(color=BRAND, size=0.7, na_rm=True)
    if rolling:
        # Taken over the series with its gaps, so a window that reaches one is
        # missing as well.
        df = df.assign(roll=_moving_average(df["value"], int(rolling)))
        p = p + geom_line(aes(y="roll"), data=df, color=ACCENT, size=1.0, na_rm=True)
    return p + labs(x="", y="Value", title=title) + theme_depictr()

Estimation and model estimates

Estimation plots and summary tables, and the model-estimate family: forests, predicted effects, interactions, random effects, posteriors and power.

These plots are shown, with the code that produced them, on the model estimates and diagnostics page of the gallery.

estimation_plot

estimation_plot(data, y, group, reference=None, conf_level=0.95, n_boot=5000, effsize='hedges_g', two_panel=False, title=None, seed=None)

Cumming estimation plot of mean differences.

For each non-reference group, the mean difference from the reference group is drawn as a point with a bootstrap confidence interval, in that group's column. The reference's column is left empty, a dashed line marks a difference of zero, and the standardised effect size is annotated beside each point. A caption names the interval and the number of resamples. With two_panel=True, the differences sit beneath a panel of the raw data and group means, each in its group's column. This is the Cumming estimation plot (Cumming, 2012) that the R twin draws, and its two panels have independent outcome axes.

Parameters:

Name Type Description Default
data DataFrame

The data.

required
y str

Name of the numeric outcome column.

required
group str

Name of the grouping column.

required
reference str

The reference (control) group the others are compared with. Defaults to the first level of group among those observed: a categorical's first category in its declared order, otherwise the first value in sorted order, with numbers sorted by value as R's factor() sorts them. The bundled datasets' groups are categoricals in the R twin's level order, so their default reference is R's. depictr 0.3.0 took the group of the first row instead, so the order of the rows decided the sign of every difference. Where that rule would have chosen another group, a FutureWarning names both. Passing reference chooses the group and silences it.

None
conf_level float

Confidence level for the bootstrap difference intervals, a single number strictly between 0 and 1.

0.95
n_boot int

Number of bootstrap resamples for each difference interval, a positive whole number. The default of 5,000 is the R twin's.

5000
effsize ('hedges_g', 'cohens_d', 'none')

Standardised effect size annotated beside each difference. Hedges' g is the small-sample corrected default; pass "none" to omit it.

"hedges_g"
two_panel bool

When True, place the differences beneath a panel of the raw data and group means, returning a composition.

False
title str

Plot title.

None
seed int

Seed for the bootstrap and, in the two-panel layout, for the jitter of the raw data, so that a seeded figure comes out the same every time.

None

Returns:

Type Description
ggplot or Compose

A single panel by default, or a two-panel composition when two_panel=True. Either carries .differences, a DataFrame of the computed mean differences, their bootstrap intervals, and effect sizes.

Examples:

>>> import depictr as dp
>>> cy = dp.crop_yield()
>>> p = dp.estimation_plot(cy, "yield", "treatment", n_boot=200, seed=1)
Source code in src/depictr/estimation.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
def estimation_plot(data, y, group, reference=None, conf_level=0.95,
                    n_boot=5000, effsize="hedges_g", two_panel=False,
                    title=None, seed=None):
    """Cumming estimation plot of mean differences.

    For each non-reference group, the mean difference from the reference group is
    drawn as a point with a bootstrap confidence interval, in that group's
    column. The reference's column is left empty, a dashed line marks a
    difference of zero, and the standardised effect size is annotated beside
    each point. A caption names the interval and the number of resamples. With
    ``two_panel=True``, the differences sit beneath a panel of the raw data and
    group means, each in its group's column. This is the Cumming estimation plot
    (Cumming, 2012) that the R twin draws, and its two panels have independent
    outcome axes.

    Parameters
    ----------
    data : pandas.DataFrame
        The data.
    y : str
        Name of the numeric outcome column.
    group : str
        Name of the grouping column.
    reference : str, optional
        The reference (control) group the others are compared with. Defaults to
        the first level of ``group`` among those observed: a categorical's first
        category in its declared order, otherwise the first value in sorted
        order, with numbers sorted by value as R's ``factor()`` sorts them. The
        bundled datasets' groups are categoricals in the R twin's level order,
        so their default reference is R's. depictr 0.3.0 took the group of the
        first row instead, so the order of the rows decided the sign of every
        difference. Where that rule would have chosen another group, a
        ``FutureWarning`` names both. Passing ``reference`` chooses the group
        and silences it.
    conf_level : float
        Confidence level for the bootstrap difference intervals, a single
        number strictly between 0 and 1.
    n_boot : int
        Number of bootstrap resamples for each difference interval, a positive
        whole number. The default of 5,000 is the R twin's.
    effsize : {"hedges_g", "cohens_d", "none"}
        Standardised effect size annotated beside each difference. Hedges' g is
        the small-sample corrected default; pass ``"none"`` to omit it.
    two_panel : bool
        When ``True``, place the differences beneath a panel of the raw data
        and group means, returning a composition.
    title : str, optional
        Plot title.
    seed : int, optional
        Seed for the bootstrap and, in the two-panel layout, for the jitter of
        the raw data, so that a seeded figure comes out the same every time.

    Returns
    -------
    plotnine.ggplot or plotnine.composition.Compose
        A single panel by default, or a two-panel composition when
        ``two_panel=True``. Either carries ``.differences``, a DataFrame of the
        computed mean differences, their bootstrap intervals, and effect sizes.

    Examples
    --------
    >>> import depictr as dp
    >>> cy = dp.crop_yield()
    >>> p = dp.estimation_plot(cy, "yield", "treatment", n_boot=200, seed=1)
    """
    if y not in data.columns:
        raise KeyError(f"{y!r} is not a column of `data`.")
    if group not in data.columns:
        raise KeyError(f"{group!r} is not a column of `data`.")
    if not pd.api.types.is_numeric_dtype(data[y]):
        raise TypeError("`y` must be numeric.")
    if effsize not in {"hedges_g", "cohens_d", "none"}:
        raise ValueError("`effsize` must be 'hedges_g', 'cohens_d' or 'none'.")
    _check_conf_level(conf_level)
    # A fraction used to fail deep inside NumPy's sampler. A whole number held
    # in a float, such as 1e4, is taken as the integer it is.
    if (isinstance(n_boot, bool) or not isinstance(n_boot, numbers.Real)
            or not float(n_boot).is_integer() or n_boot < 1):
        raise ValueError("`n_boot` must be a positive integer.")
    n_boot = int(n_boot)

    d = data[[y, group]].dropna()
    # Levels in a categorical's declared order, otherwise sorted, leaving out
    # any category no row falls in. The reference decides the sign of every
    # difference, so it must not depend on which row comes first. Numbers are
    # sorted by value, as R's factor() sorts them. _levels() sorts them as
    # text, which would put a group of 10 before a group of 5.
    if pd.api.types.is_numeric_dtype(d[group]):
        levels = sorted(d[group].unique())
    else:
        observed = set(d[group])
        levels = [lvl for lvl in _levels(d[group]) if lvl in observed]
    if len(levels) < 2:
        raise ValueError("`group` must have at least two non-empty levels.")

    if reference is None:
        ref = levels[0]
        first_row = d[group].iloc[0]
        if first_row != ref:
            warnings.warn(
                f"estimation_plot() took {_shown(ref)} as the reference, the "
                f"first level of {group!r} (in a categorical's declared order, "
                "otherwise in sorted order). depictr 0.3.0 took the group of "
                f"the first row, {_shown(first_row)}, so these differences are "
                "not those of a figure made with 0.3.0. Pass `reference` to "
                "choose the group and silence this warning.",
                FutureWarning, stacklevel=2,
            )
    else:
        ref = reference
    if ref not in levels:
        raise ValueError(f"`reference` ({ref!r}) is not a level of `group`.")
    others = [g for g in levels if g != ref]
    # Both panels give every group a column, the reference's first.
    groups = [ref, *others]

    rng = np.random.default_rng(seed)
    ref_vals = d.loc[d[group] == ref, y].to_numpy(dtype=float)

    rows = []
    for g in others:
        gv = d.loc[d[group] == g, y].to_numpy(dtype=float)
        md = gv.mean() - ref_vals.mean()
        lo, hi = _boot_diff_ci(gv, ref_vals, conf_level, n_boot, rng)
        cohens_d, hedges_g = _effsize_diff(gv, ref_vals)
        rows.append({"group": g, "reference": ref, "diff": md,
                     "lower": lo, "upper": hi,
                     "cohens_d": cohens_d, "hedges_g": hedges_g})
    diffs = pd.DataFrame(rows)
    # The non-reference groups, in the order of their columns.
    diffs["group"] = pd.Categorical(diffs["group"], categories=others, ordered=True)

    brand = depictr_brand()
    p = (
        ggplot(diffs, aes(x="group", y="diff"))
        + geom_hline(yintercept=0, linetype="dashed", color="#9e9e9e", size=0.4)
        + geom_errorbar(aes(ymin="lower", ymax="upper"), width=0.12,
                        color=brand, size=0.8, na_rm=True)
        + geom_point(color=brand, size=2.8)
    )

    if effsize != "none":
        prefix = "Hedges' g = " if effsize == "hedges_g" else "Cohen's d = "
        lab = diffs[np.isfinite(diffs[effsize])].copy()
        if len(lab):
            lab["es_label"] = prefix + lab[effsize].map(lambda v: f"{v:.2f}")
            # Sit the label above the upper cap (or the point when no interval).
            lab["lab_y"] = np.where(np.isfinite(lab["upper"]),
                                    lab["upper"], lab["diff"])
            p = (p
                 + geom_text(aes(x="group", y="lab_y", label="es_label"),
                             data=lab, va="bottom", nudge_y=0.0,
                             color="#404040", size=9)
                 # Reserve headroom so the annotation is not clipped.
                 + scale_y_continuous(expand=(0.08, 0, 0.3, 0)))

    p = (p
         # A column for every group, in the upper panel's order, so that each
         # difference sits beneath its own group, and an empty, unlabelled one
         # for the reference. The limits name every group because
         # expand_limits() cannot add a slot to explicit discrete limits.
         + scale_x_discrete(limits=groups,
                            labels=["" if g == ref else str(g) for g in groups])
         + labs(x="", y=f"Mean difference\n(vs. {ref})",
                title=None if two_panel else title,
                caption=f"Bootstrap {100 * conf_level:g}% CI "
                        f"({n_boot:,} resamples)")
         + theme_depictr(grid="y")
         + theme(axis_text_x=element_text(weight="bold")))
    p.differences = diffs
    if not two_panel:
        return p

    # Top panel: the raw data with each group's mean and a t-based interval, on
    # the outcome scale, in the same columns as the differences below.
    from scipy import stats

    summ = []
    for g in groups:
        v = d.loc[d[group] == g, y].to_numpy(dtype=float)
        n = len(v)
        m = float(v.mean())
        if n > 1:
            se = float(v.std(ddof=1)) / np.sqrt(n)
            tc = float(stats.t.ppf(1 - (1 - conf_level) / 2, n - 1))
        else:
            # nan, not zero: a single observation has no interval, and a
            # zero-width one would draw a cap pair at the mean claiming perfect
            # precision while the difference panel below reports the same group
            # as undefined. Omitting it is what _boot_diff_ci already does.
            se = tc = np.nan
        summ.append({"group": g, "mean": m, "lo": m - tc * se, "hi": m + tc * se})
    summ_df = pd.DataFrame(summ)
    summ_df["group"] = pd.Categorical(summ_df["group"], categories=groups, ordered=True)

    # plotnine reads a seed of 0 as no seed, and from an integer seed it draws
    # the same numbers for the horizontal and the vertical jitter. A RandomState
    # avoids both. plotnine copies it before each draw, so the figure jitters
    # the same way every time it is drawn.
    jitter_state = (None if seed is None
                    else np.random.RandomState(np.random.MT19937(seed)))
    # The raw data's groups as a categorical in the columns' order, as R makes
    # a factor of them. A numeric group would otherwise be drawn at its value
    # on a continuous axis, far from its column.
    raw = d.assign(**{group: pd.Categorical(d[group], categories=groups)})
    top = (
        ggplot(summ_df, aes(x="group", y="mean"))
        + geom_jitter(aes(x=group, y=y), data=raw, width=0.12, alpha=0.25,
                      color=brand, size=0.9, inherit_aes=False,
                      random_state=jitter_state)
        + geom_errorbar(aes(ymin="lo", ymax="hi"), width=0.12, color=brand,
                        size=0.8, na_rm=True)
        + geom_point(color=brand, size=2.8)
        + scale_x_discrete(limits=groups)
        # The title rides on the top panel so it reads as the figure title
        # (plotnine compositions have no super-title).
        + labs(x="", y=y, title=title)
        + theme_depictr(grid="y")
        + theme(axis_text_x=element_text(weight="bold"))
    )
    # Untagged, as the R twin's two patchwork panels are.
    composed = arrange_plots(top, p, ncol=1, tag_levels=None)
    composed.differences = diffs
    return composed

summary_table

summary_table(data, vars=None, group=None, digits=1, missing=True, max_levels=20)

Build a "Table 1" descriptive summary.

The first row always reports the sample size (N) overall and per group. Numeric variables are summarised as mean (SD); categorical variables get one row per level with count (percent). A Missing, n (%) row follows any variable that has missing values, unless missing is False.

A datetime column gets a single Range row, written as <earliest> to <latest> in each column. The cells give the date alone when every time in the column falls at midnight, and the time to the minute, or to the second when any time has seconds. A timedelta column is summarised as mean (SD) in the largest of days, hours, minutes and seconds that its median duration reaches, and the statistic names the unit, as in Mean (SD), days. The R twin reports a difftime in the units it carries, which a timedelta lacks.

Parameters:

Name Type Description Default
data DataFrame

The data.

required
vars list of str

Columns to summarise. If None, every column except group is used, with high-cardinality identifier-like columns skipped (see max_levels).

None
group str

A grouping column; one summary column is produced per level, alongside an overall column. Categorical levels keep their declared category order and everything else is sorted, so the table does not depend on the row order. Records whose group value is missing get a Missing column, so the group sizes always sum to the overall N.

None
digits int

Decimal places for the numeric summaries.

1
missing bool

Add a Missing, n (%) row for variables that contain missing values.

True
max_levels int

When vars is None, a categorical or text column whose distinct-value count is at least max_levels and exceeds half the number of rows is treated as an identifier and skipped with a UserWarning that names it, so it does not explode into one row per value. Pass such a column in vars to include it.

20

Returns:

Type Description
DataFrame

Columns variable, statistic, Overall, one column per group level, and a trailing Missing column when the group column has any missing values. The first row reports N. The variable name is blanked on its repeated rows for readability.

Examples:

>>> import depictr as dp
>>> wb = dp.wellbeing_survey()
>>> tab = dp.summary_table(wb, vars=["life_satisfaction", "stress"], group="region")
>>> tab.columns.tolist()[0]
'variable'
Source code in src/depictr/tables.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def summary_table(data, vars=None, group=None, digits=1, missing=True,
                  max_levels=20):
    """Build a "Table 1" descriptive summary.

    The first row always reports the sample size (``N``) overall and per group.
    Numeric variables are summarised as mean (SD); categorical variables get one
    row per level with count (percent). A ``Missing, n (%)`` row follows any
    variable that has missing values, unless ``missing`` is ``False``.

    A datetime column gets a single ``Range`` row, written as ``<earliest> to
    <latest>`` in each column. The cells give the date alone when every time in
    the column falls at midnight, and the time to the minute, or to the second
    when any time has seconds. A timedelta column is summarised as mean (SD) in
    the largest of days, hours, minutes and seconds that its median duration
    reaches, and the statistic names the unit, as in ``Mean (SD), days``. The R
    twin reports a ``difftime`` in the units it carries, which a timedelta
    lacks.

    Parameters
    ----------
    data : pandas.DataFrame
        The data.
    vars : list of str, optional
        Columns to summarise. If ``None``, every column except ``group`` is
        used, with high-cardinality identifier-like columns skipped (see
        ``max_levels``).
    group : str, optional
        A grouping column; one summary column is produced per level, alongside an
        overall column. Categorical levels keep their declared category order and
        everything else is sorted, so the table does not depend on the row order.
        Records whose group value is missing get a ``Missing`` column, so the
        group sizes always sum to the overall ``N``.
    digits : int
        Decimal places for the numeric summaries.
    missing : bool
        Add a ``Missing, n (%)`` row for variables that contain missing values.
    max_levels : int
        When ``vars`` is ``None``, a categorical or text column whose
        distinct-value count is at least ``max_levels`` and exceeds half the
        number of rows is treated as an identifier and skipped with a
        ``UserWarning`` that names it, so it does not explode into one row per
        value. Pass such a column in ``vars`` to include it.

    Returns
    -------
    pandas.DataFrame
        Columns ``variable``, ``statistic``, ``Overall``, one column per group
        level, and a trailing ``Missing`` column when the group column has any
        missing values. The first row reports ``N``. The variable name is blanked
        on its repeated rows for readability.

    Examples
    --------
    >>> import depictr as dp
    >>> wb = dp.wellbeing_survey()
    >>> tab = dp.summary_table(wb, vars=["life_satisfaction", "stress"], group="region")
    >>> tab.columns.tolist()[0]
    'variable'
    """
    if not isinstance(data, pd.DataFrame):
        raise TypeError("`data` must be a pandas DataFrame.")
    if group is not None and group not in data.columns:
        raise KeyError(f"group column {group!r} not found.")

    n = len(data)
    if vars is None:
        vars = [c for c in data.columns if c != group]
        # Drop identifier-like columns: a column summarised level by level with
        # nearly as many distinct values as rows would expand into hundreds of
        # useless rows. Dates and durations take one row, so they stay.
        kept, skipped = [], []
        for v in vars:
            col = data[v]
            if not _is_continuous(col):
                n_lev = col.dropna().nunique()
                if n_lev >= max_levels and n_lev > n / 2:
                    skipped.append(v)
                    continue
            kept.append(v)
        vars = kept
        if skipped:
            # The R twin's message, in a warning. The columns used to vanish
            # without a word.
            warnings.warn(
                "summary_table(): skipping high-cardinality column(s): "
                f"{', '.join(str(v) for v in skipped)}. "
                "Pass them via `vars` to include them.",
                UserWarning,
                stacklevel=2,
            )

    missing_cols = [v for v in vars if v not in data.columns]
    if missing_cols:
        raise KeyError(f"column(s) not found: {missing_cols}")

    # One sub-frame per output column: the whole data plus, if grouping, a split.
    columns = {"Overall": data}
    if group is not None:
        # _levels rather than first-appearance order, the same helper the level
        # rows use: a categorical keeps its declared category order and anything
        # else is sorted, so shuffling the rows cannot reorder the table.
        for lvl in _levels(data[group]):
            columns[str(lvl)] = data[data[group] == lvl]
        # Records with no group value need a column of their own. Without one
        # they fall out of every group while Overall still counts them, so the
        # group sizes stop summing to N with nothing to say why.
        if data[group].isna().any():
            na_name = "Missing"
            while na_name in columns:  # a level may itself be called "Missing"
                na_name += "_"
            columns[na_name] = data[data[group].isna()]
    col_names = list(columns.keys())

    def num_cell(v):
        v = pd.Series(v).dropna()
        if len(v) == 0:
            return "--"
        if len(v) == 1:
            # SD is undefined for a single observation; report the mean alone.
            return f"{v.mean():.{digits}f} (n=1)"
        return f"{v.mean():.{digits}f} ({v.std(ddof=1):.{digits}f})"

    def cat_cell(col, level):
        x = col.dropna()
        tot = len(x)
        if tot == 0:
            return "--"
        k = int((x == level).sum())
        return f"{k} ({100 * k / tot:.0f}%)"

    def miss_cell(col):
        tot = len(col)
        if tot == 0:
            return "--"
        k = int(col.isna().sum())
        return f"{k} ({100 * k / tot:.0f}%)"

    rows = []

    # Sample size first.
    row = {"variable": "N", "statistic": ""}
    for name in col_names:
        row[name] = str(len(columns[name]))
    rows.append(row)

    for v in vars:
        col = data[v]
        if is_datetime64_any_dtype(col):
            # A date is not a level: expanded as one, it gave a row per day.
            fmt = _date_cell_format(col)
            row = {"variable": v, "statistic": "Range"}
            for name in col_names:
                row[name] = _date_range_cell(columns[name][v], fmt)
            rows.append(row)
        elif is_timedelta64_dtype(col):
            unit, size = _duration_unit(col)
            row = {"variable": v, "statistic": f"Mean (SD), {unit}"}
            for name in col_names:
                row[name] = num_cell(columns[name][v].dt.total_seconds() / size)
            rows.append(row)
        elif is_numeric_dtype(col):
            row = {"variable": v, "statistic": "Mean (SD)"}
            for name in col_names:
                row[name] = num_cell(columns[name][v])
            rows.append(row)
        else:
            for level in _levels(col):
                row = {"variable": v, "statistic": str(level)}
                for name in col_names:
                    row[name] = cat_cell(columns[name][v], level)
                rows.append(row)
        if missing and col.isna().any():
            row = {"variable": v, "statistic": "Missing, n (%)"}
            for name in col_names:
                row[name] = miss_cell(columns[name][v])
            rows.append(row)

    out = pd.DataFrame(rows, columns=["variable", "statistic"] + col_names)
    # Blank the repeated variable name so each variable reads as one block.
    out.loc[out["variable"].duplicated(), "variable"] = ""
    return out

coefficient_plot

coefficient_plot(model, intercept=False, order='model', conf_level=0.95, title=None, reference_line=None, exponentiate=False, labels=None, interaction='times')

Forest (dot-and-whisker) plot of model coefficients.

By default each term is labelled for a reader. The coefficient of a factor level names its variable and level, so patsy's treatment[T.enhanced] is drawn as treatment: enhanced, the contrast of enhanced with the reference level, and rainfall:treatment[T.enhanced] as rainfall ร— treatment: enhanced. For a formula fit, the levels are read from the model's design, so a level holding a colon or a bracket is kept whole. Other names, and the terms of a data frame, are tidied with :func:format_terms, which also drops the Q() and C() wrappers of a formula. The R twin labels its terms by the same rules.

Parameters:

Name Type Description Default
model statsmodels results object or pandas.DataFrame

See :func:tidy_estimates.

required
intercept bool

Whether to include the intercept term.

False
order ('model', 'none', 'ascending', 'descending')

Order of the terms. "ascending" puts the smallest estimate at the bottom and the largest at the top, so estimates increase up the axis. "descending" does the reverse, and "model" keeps the model's order from top to bottom. "none", the R twin's name for model order, is accepted as an alias. The R twin reads order the same way. Any other value raises ValueError.

"model"
conf_level float

Confidence level passed to :func:tidy_estimates, a single number strictly between 0 and 1.

0.95
title str
None
reference_line float

Position of the dashed vertical reference line. None, the default, puts it at 0, or at 1 when exponentiate is set, where a ratio of 1 means no effect. float("nan") leaves it out, as NA does in the R twin.

None
exponentiate bool

Whether to draw the exponentiated estimates and intervals on a log10 axis, which shows the coefficients of a logistic regression as odds ratios and those of a Poisson or negative binomial model as rate ratios. The axis is labelled "Odds ratio" for a logit link, "Rate ratio" for a log link in a count model, "Risk ratio" for a binomial model with a log link and "exp(Estimate)" otherwise. A data frame model must hold estimates on the link scale. Defaults to False.

False
labels (dict, list or 'raw')

Display labels for the terms. A dict maps raw term names to labels, such as {"soil_ph": "Soil pH"}, and leaves the other terms to the default labels. A key that matches no term warns, since a mistyped one would otherwise change nothing. A list gives one label per term in model order (the order :func:tidy_estimates returns, after the intercept is dropped unless intercept=True), whatever order is, so each label stays with its term when the terms are sorted. Under the default order, it reads from top to bottom. "raw" draws the names as the model gives them. Two terms may not share a label.

None
interaction ('times', 'asterisk', 'colon', 'space')

How an interaction is written in the default labels, passed to :func:format_terms: a multiplication sign by default.

"times"

Returns:

Type Description
ggplot

The plot's data keeps the raw names in term and the drawn ones in label.

Examples:

>>> import depictr as dp
>>> import statsmodels.formula.api as smf
>>> cy = dp.crop_yield()
>>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + treatment', cy).fit()
>>> p = dp.coefficient_plot(model, title="Drivers of crop yield")
>>> list(p.data["label"].cat.categories)[::-1]
['treatment: enhanced', 'fertiliser', 'rainfall']
>>> p = dp.coefficient_plot(model, labels={"treatment[T.enhanced]": "Enhanced"})

Odds ratios from a logistic regression, on a log axis with the line at 1:

>>> ld = dp.lexical_decision()
>>> logit = smf.logit("accuracy ~ word_frequency + condition", ld).fit(disp=0)
>>> p = dp.coefficient_plot(logit, exponentiate=True)
Source code in src/depictr/models.py
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
def coefficient_plot(model, intercept: bool = False, order: str = "model",
                     conf_level: float = 0.95, title=None, reference_line=None,
                     exponentiate: bool = False, labels=None,
                     interaction: str = "times"):
    """Forest (dot-and-whisker) plot of model coefficients.

    By default each term is labelled for a reader. The coefficient of a factor
    level names its variable and level, so patsy's ``treatment[T.enhanced]``
    is drawn as ``treatment: enhanced``, the contrast of enhanced with the
    reference level, and ``rainfall:treatment[T.enhanced]`` as
    ``rainfall ร— treatment: enhanced``. For a formula fit, the levels are read
    from the model's design, so a level holding a colon or a bracket is kept
    whole. Other names, and the terms of a data frame, are tidied with
    :func:`format_terms`, which also drops the ``Q()`` and ``C()`` wrappers of
    a formula. The R twin labels its terms by the same rules.

    Parameters
    ----------
    model : statsmodels results object or pandas.DataFrame
        See :func:`tidy_estimates`.
    intercept : bool
        Whether to include the intercept term.
    order : {"model", "none", "ascending", "descending"}
        Order of the terms. ``"ascending"`` puts the smallest estimate at the
        bottom and the largest at the top, so estimates increase up the axis.
        ``"descending"`` does the reverse, and ``"model"`` keeps the model's
        order from top to bottom. ``"none"``, the R twin's name for model
        order, is accepted as an alias. The R twin reads ``order`` the same
        way. Any other value raises ``ValueError``.
    conf_level : float
        Confidence level passed to :func:`tidy_estimates`, a single number
        strictly between 0 and 1.
    title : str, optional
    reference_line : float, optional
        Position of the dashed vertical reference line. ``None``, the default,
        puts it at 0, or at 1 when ``exponentiate`` is set, where a ratio of 1
        means no effect. ``float("nan")`` leaves it out, as ``NA`` does in the
        R twin.
    exponentiate : bool
        Whether to draw the exponentiated estimates and intervals on a log10
        axis, which shows the coefficients of a logistic regression as odds
        ratios and those of a Poisson or negative binomial model as rate
        ratios. The axis is labelled "Odds ratio" for a logit link, "Rate
        ratio" for a log link in a count model, "Risk ratio" for a binomial
        model with a log link and "exp(Estimate)" otherwise. A data frame
        ``model`` must hold estimates on the link scale. Defaults to
        ``False``.
    labels : dict, list or "raw", optional
        Display labels for the terms. A dict maps raw term names to labels,
        such as ``{"soil_ph": "Soil pH"}``, and leaves the other terms to the
        default labels. A key that matches no term warns, since a mistyped one
        would otherwise change nothing. A list gives one label per term in
        model order (the order :func:`tidy_estimates` returns, after the
        intercept is dropped unless ``intercept=True``), whatever ``order``
        is, so each label stays with its term when the terms are sorted. Under
        the default order, it reads from top to bottom. ``"raw"`` draws the
        names as the model gives them. Two terms may not share a label.
    interaction : {"times", "asterisk", "colon", "space"}
        How an interaction is written in the default labels, passed to
        :func:`format_terms`: a multiplication sign by default.

    Returns
    -------
    plotnine.ggplot
        The plot's data keeps the raw names in ``term`` and the drawn ones in
        ``label``.

    Examples
    --------
    >>> import depictr as dp
    >>> import statsmodels.formula.api as smf
    >>> cy = dp.crop_yield()
    >>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + treatment', cy).fit()
    >>> p = dp.coefficient_plot(model, title="Drivers of crop yield")
    >>> list(p.data["label"].cat.categories)[::-1]
    ['treatment: enhanced', 'fertiliser', 'rainfall']
    >>> p = dp.coefficient_plot(model, labels={"treatment[T.enhanced]": "Enhanced"})

    Odds ratios from a logistic regression, on a log axis with the line at 1:

    >>> ld = dp.lexical_decision()
    >>> logit = smf.logit("accuracy ~ word_frequency + condition", ld).fit(disp=0)
    >>> p = dp.coefficient_plot(logit, exponentiate=True)
    """
    _check_conf_level(conf_level)
    if not isinstance(order, str) or order not in _ORDERS:
        # An unknown order, a typo among them, used to fall back to model
        # order without a word. R's match.arg() refuses it.
        raise ValueError(
            "`order` must be one of 'model' (or its alias 'none'), "
            "'ascending' or 'descending'."
        )
    _check_exponentiate(exponentiate)
    interaction_separator(interaction)
    _check_labels(labels)
    line = _reference_position(reference_line, exponentiate)
    est = tidy_estimates(model, conf_level=conf_level, exponentiate=exponentiate)
    # Judged against every term, so labelling one that is merely not drawn,
    # such as the dropped intercept, is not reported as a typo.
    _warn_unused_labels(est["term"], labels)
    auto = {} if isinstance(labels, str) else _design_labels(model, interaction)
    intercept_dropped = False
    if not intercept:
        is_intercept = est["term"].str.fullmatch(_INTERCEPT_PATTERN)
        intercept_dropped = bool(is_intercept.any())
        est = est[~is_intercept]
    # Dropping the only term, the intercept of an intercept-only model, left a
    # themed but empty panel. The R twin's check and message.
    if est.empty:
        raise ValueError("No terms left to plot (did you drop the only term?).")
    # A list is read in model order, so it is attached to the rows before they
    # are sorted, and each label travels with its own term.
    if isinstance(labels, (list, tuple)):
        _check_label_count(labels, len(est), "the model has", "in model order",
                           intercept_dropped)
        est = est.assign(label=[str(label) for label in labels])
    else:
        est = est.assign(label=_term_labels(est["term"], labels, auto, interaction))
    _check_distinct(est["term"], est["label"])
    # plotnine draws the first category of the y axis at the bottom. A sorted
    # order is therefore listed as it stands, so estimates increase up the axis
    # under "ascending", while model order is reversed so that the first term
    # reads at the top. The stable sort keeps tied terms in model order, as R's
    # order() does.
    if order == "ascending":
        est = est.sort_values("estimate", kind="stable")
    elif order == "descending":
        est = est.sort_values("estimate", ascending=False, kind="stable")
    terms, drawn = list(est["term"]), list(est["label"])
    if order not in ("ascending", "descending"):
        terms, drawn = terms[::-1], drawn[::-1]
    est = est.assign(
        term=pd.Categorical(est["term"], categories=list(dict.fromkeys(terms)),
                            ordered=True),
        label=pd.Categorical(est["label"], categories=drawn, ordered=True),
    )
    p = ggplot(est, aes(x="estimate", y="label"))
    if line is not None:
        p = p + geom_vline(xintercept=line, linetype="dashed", color="#9e9e9e")
    p = (
        p
        + geom_errorbarh(aes(xmin="conf_low", xmax="conf_high"), height=0.15,
                         color=depictr_brand(), size=0.8)
        + geom_point(color=depictr_brand(), size=2.6)
        + labs(x=_ratio_label(model) if exponentiate else "Estimate", y="",
               title=title)
        + theme_depictr()
    )
    # Ratios are symmetric on a log scale, where 0.5 and 2 sit equally far
    # from 1.
    return p + scale_x_log10() if exponentiate else p

tidy_estimates

tidy_estimates(model, conf_level=0.95, exponentiate=False)

Coerce a fitted model or an estimate table into one tidy frame.

Parameters:

Name Type Description Default
model statsmodels results object or pandas.DataFrame

A fitted model exposing params and conf_int (for example a statsmodels OLS, GLM or MixedLM result, fitted from a formula or from arrays), or a data frame already carrying the estimates.

required
conf_level float

Confidence level for the interval when reading it from a model, a single number strictly between 0 and 1.

0.95
exponentiate bool

Whether to exponentiate the estimates and interval limits, which turns log-odds into odds ratios and log rates into rate ratios. A data frame is read as estimates on the link scale. Defaults to False.

False

Returns:

Type Description
DataFrame

Columns term, estimate, conf_low, conf_high.

Notes

The intervals are those of the result's conf_int(), so they follow its use_t. An OLS result uses t. A GLM uses z by default, as statsmodels' own GLM summary does, and t on the residual degrees of freedom when fitted with use_t=True. The R twin gives a family whose dispersion is estimated (Gaussian, Gamma, inverse Gaussian) the t interval that R's summary.glm() tests with, so fit such a GLM with use_t=True to get the R twin's intervals. At 12 residual degrees of freedom, the t interval is about 11% wider than the z interval, and at 100 about 1% wider.

For a mixed model, only the fixed effects are kept, as the R twin keeps lme4::fixef(). The variance and covariance entries that follow them in a MixedLM result's params are not coefficients, and statsmodels reports them divided by the residual scale. The intervals kept are Wald intervals on the normal distribution, as the R twin computes them for an lme4 fit.

A model fitted from arrays has unnamed estimates, so its terms are named from the model's exog_names (const, x1, ...), as statsmodels' own summary names them. Terms read from a model are always strings, as in R, so the numbered columns of a data frame made from an array give the terms "0", "1" and so on.

Examples:

>>> import depictr as dp
>>> import statsmodels.formula.api as smf
>>> cy = dp.crop_yield()
>>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
>>> dp.tidy_estimates(model).columns.tolist()
['term', 'estimate', 'conf_low', 'conf_high']
Source code in src/depictr/models.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def tidy_estimates(model, conf_level: float = 0.95,
                   exponentiate: bool = False) -> pd.DataFrame:
    """Coerce a fitted model or an estimate table into one tidy frame.

    Parameters
    ----------
    model : statsmodels results object or pandas.DataFrame
        A fitted model exposing ``params`` and ``conf_int`` (for example a
        statsmodels ``OLS``, ``GLM`` or ``MixedLM`` result, fitted from a formula
        or from arrays), or a data frame already carrying the estimates.
    conf_level : float
        Confidence level for the interval when reading it from a model, a
        single number strictly between 0 and 1.
    exponentiate : bool
        Whether to exponentiate the estimates and interval limits, which turns
        log-odds into odds ratios and log rates into rate ratios. A data frame
        is read as estimates on the link scale. Defaults to ``False``.

    Returns
    -------
    pandas.DataFrame
        Columns ``term``, ``estimate``, ``conf_low``, ``conf_high``.

    Notes
    -----
    The intervals are those of the result's ``conf_int()``, so they follow its
    ``use_t``. An OLS result uses t. A GLM uses z by default, as statsmodels'
    own GLM summary does, and t on the residual degrees of freedom when fitted
    with ``use_t=True``. The R twin gives a family whose dispersion is
    estimated (Gaussian, Gamma, inverse Gaussian) the t interval that R's
    ``summary.glm()`` tests with, so fit such a GLM with ``use_t=True`` to get
    the R twin's intervals. At 12 residual degrees of freedom, the t interval
    is about 11% wider than the z interval, and at 100 about 1% wider.

    For a mixed model, only the fixed effects are kept, as the R twin keeps
    ``lme4::fixef()``. The variance and covariance entries that follow them in
    a ``MixedLM`` result's ``params`` are not coefficients, and statsmodels
    reports them divided by the residual scale. The intervals kept are Wald
    intervals on the normal distribution, as the R twin computes them for an
    lme4 fit.

    A model fitted from arrays has unnamed estimates, so its terms are named
    from the model's ``exog_names`` (``const``, ``x1``, ...), as statsmodels'
    own summary names them. Terms read from a model are always strings, as in
    R, so the numbered columns of a data frame made from an array give the
    terms ``"0"``, ``"1"`` and so on.

    Examples
    --------
    >>> import depictr as dp
    >>> import statsmodels.formula.api as smf
    >>> cy = dp.crop_yield()
    >>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
    >>> dp.tidy_estimates(model).columns.tolist()
    ['term', 'estimate', 'conf_low', 'conf_high']
    """
    _check_conf_level(conf_level)
    _check_exponentiate(exponentiate)
    if isinstance(model, pd.DataFrame):
        return _exponentiate(_tidy_from_frame(model), exponentiate)
    if hasattr(model, "params") and hasattr(model, "conf_int"):
        return _exponentiate(_tidy_from_results(model, conf_level), exponentiate)
    raise TypeError(
        "`model` must be a fitted statsmodels result or a tidy DataFrame with "
        "columns term/estimate/conf_low/conf_high."
    )

effects_plot

effects_plot(model, var, conf_level=0.95, n=100, title=None)

Predicted response as one predictor varies, with a confidence band.

The predictor var is swept across its observed range while every other numeric predictor is held at its mean and every categorical at its most common level (the mode). The line is the predicted response and the ribbon its confidence band, both read from statsmodels' get_prediction. The band takes t or z as the result's use_t says, so a GLM whose dispersion is estimated, fitted with use_t=True, gets the t band of the R twin.

The predictors are the columns the formula names, so a column of the source frame outside the model plays no part. The range, means and levels come from the rows the model was fitted to, without the rows its missing-value handling dropped. A formula term computed from statistics of its rows, such as I(x - x.mean()), is refused with ValueError, since get_prediction() would compute it afresh over the grid. patsy's center() and standardize() keep the values of the fit and are safe. A model fitted with an offset is predicted at the mean offset over the estimation rows, and one fitted with an exposure at the geometric mean exposure. The y-axis label then ends in "at the mean offset".

Parameters:

Name Type Description Default
model statsmodels results object

A fitted OLS/GLM result from a formula and a DataFrame (so the source data is retained for building the grid).

required
var str

The predictor to vary along the x-axis: a numeric column that the formula names.

required
conf_level float

Confidence level for the band, a single number strictly between 0 and 1.

0.95
n int

Number of points across the predictor's range.

100
title str

Plot title.

None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import statsmodels.formula.api as smf
>>> cy = dp.crop_yield()
>>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
>>> p = dp.effects_plot(model, "fertiliser")
Source code in src/depictr/predictions.py
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
def effects_plot(model, var, conf_level: float = 0.95, n: int = 100, title=None):
    """Predicted response as one predictor varies, with a confidence band.

    The predictor ``var`` is swept across its observed range while every other
    numeric predictor is held at its mean and every categorical at its most
    common level (the mode). The line is the predicted response and the ribbon its
    confidence band, both read from statsmodels' ``get_prediction``. The band
    takes t or z as the result's ``use_t`` says, so a GLM whose dispersion is
    estimated, fitted with ``use_t=True``, gets the t band of the R twin.

    The predictors are the columns the formula names, so a column of the source
    frame outside the model plays no part. The range, means and levels come from
    the rows the model was fitted to, without the rows its missing-value
    handling dropped. A formula term computed from statistics of its rows, such
    as ``I(x - x.mean())``, is refused with ``ValueError``, since
    ``get_prediction()`` would compute it afresh over the grid. patsy's
    ``center()`` and ``standardize()`` keep the values of the fit and are safe.
    A model fitted with an ``offset`` is predicted at the mean offset over the
    estimation rows, and one fitted with an ``exposure`` at the geometric mean
    exposure. The y-axis label then ends in "at the mean offset".

    Parameters
    ----------
    model : statsmodels results object
        A fitted OLS/GLM result from a formula and a DataFrame (so the source
        data is retained for building the grid).
    var : str
        The predictor to vary along the x-axis: a numeric column that the
        formula names.
    conf_level : float
        Confidence level for the band, a single number strictly between 0 and
        1.
    n : int
        Number of points across the predictor's range.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import statsmodels.formula.api as smf
    >>> cy = dp.crop_yield()
    >>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
    >>> p = dp.effects_plot(model, "fertiliser")
    """
    _require_statsmodels()
    if not _is_fitted_model(model):
        raise TypeError("effects_plot needs a fitted statsmodels result.")
    _check_conf_level(conf_level)

    frame = _estimation_frame(model)
    predictors = _predictor_columns(model, frame)
    if var not in predictors:
        raise KeyError(f"{var!r} is not a predictor in the fitted model.")
    if not pd.api.types.is_numeric_dtype(frame[var]):
        raise TypeError(
            f"effects_plot varies {var!r} continuously, so it must be numeric. "
            "Use interaction_plot for a categorical predictor."
        )
    _check_grid_terms(model, frame, predictors)

    others = [c for c in predictors if c != var]
    grid = pd.DataFrame({var: np.linspace(frame[var].min(), frame[var].max(), n)})
    for col, value in _reference_row(frame, others).items():
        grid[col] = value

    band = _prediction_band(model, grid, conf_level)
    df = pd.concat([grid[[var]].reset_index(drop=True), band], axis=1)

    return (
        ggplot(df, aes(x=var, y="fit"))
        + geom_ribbon(aes(ymin="lower", ymax="upper"), fill=BRAND, alpha=0.2)
        + geom_line(color=BRAND, size=0.9)
        + labs(x=var, y=_prediction_label(model), title=title)
        + theme_depictr()
    )

interaction_plot

interaction_plot(model, x, group, conf_level=0.95, n=100, band=True, title=None)

Predicted response across x for each level of a categorical group.

One coloured line per group shows how the predicted response changes with x within that group, so a fan of non-parallel lines is the visible mark of an interaction. The remaining predictors are held at their mean if numeric, and at their most common level (the mode) if categorical. As in :func:effects_plot, the predictors are the columns the formula names, the range, means and levels come from the rows the model was fitted to, a term computed from statistics of its rows is refused, and an offset or exposure is held at its mean.

Parameters:

Name Type Description Default
model statsmodels results object

A fitted OLS/GLM result from a formula and a DataFrame.

required
x str

The numeric predictor on the x-axis.

required
group str

The categorical predictor whose levels are drawn as separate lines. A level that occurs only in rows the fit dropped is not drawn.

required
conf_level float

Confidence level for the bands, a single number strictly between 0 and 1.

0.95
n int

Number of points across the x range, per group.

100
band bool

Whether to draw a confidence ribbon behind each line.

True
title str

Plot title.

None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import statsmodels.formula.api as smf
>>> cy = dp.crop_yield()
>>> model = smf.ols('Q("yield") ~ fertiliser * treatment', cy).fit()
>>> p = dp.interaction_plot(model, "fertiliser", "treatment")
Source code in src/depictr/predictions.py
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
def interaction_plot(model, x, group, conf_level: float = 0.95, n: int = 100,
                     band: bool = True, title=None):
    """Predicted response across ``x`` for each level of a categorical ``group``.

    One coloured line per group shows how the predicted response changes with
    ``x`` within that group, so a fan of non-parallel lines is the visible mark of
    an interaction. The remaining predictors are held at their mean if numeric,
    and at their most common level (the mode) if categorical. As in
    :func:`effects_plot`, the predictors are the columns the formula names, the
    range, means and levels come from the rows the model was fitted to, a term
    computed from statistics of its rows is refused, and an offset or exposure
    is held at its mean.

    Parameters
    ----------
    model : statsmodels results object
        A fitted OLS/GLM result from a formula and a DataFrame.
    x : str
        The numeric predictor on the x-axis.
    group : str
        The categorical predictor whose levels are drawn as separate lines. A
        level that occurs only in rows the fit dropped is not drawn.
    conf_level : float
        Confidence level for the bands, a single number strictly between 0
        and 1.
    n : int
        Number of points across the x range, per group.
    band : bool
        Whether to draw a confidence ribbon behind each line.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import statsmodels.formula.api as smf
    >>> cy = dp.crop_yield()
    >>> model = smf.ols('Q("yield") ~ fertiliser * treatment', cy).fit()
    >>> p = dp.interaction_plot(model, "fertiliser", "treatment")
    """
    _require_statsmodels()
    if not _is_fitted_model(model):
        raise TypeError("interaction_plot needs a fitted statsmodels result.")
    _check_conf_level(conf_level)

    frame = _estimation_frame(model)
    predictors = _predictor_columns(model, frame)
    for name in (x, group):
        if name not in predictors:
            raise KeyError(f"{name!r} is not a predictor in the fitted model.")
    if not pd.api.types.is_numeric_dtype(frame[x]):
        raise TypeError(f"interaction_plot needs a numeric x; {x!r} is not.")
    _check_grid_terms(model, frame, predictors)

    others = [c for c in predictors if c not in (x, group)]
    held = _reference_row(frame, others)
    x_seq = np.linspace(frame[x].min(), frame[x].max(), n)
    # dropna before the levels are taken: the loop below coerces each level with
    # str(), which would turn a missing group value into a real "nan" line and
    # legend entry. Every other grouped function in the package drops first.
    levels = list(pd.unique(frame[group].dropna()))
    if not levels:
        raise ValueError(f"{group!r} has no non-missing values to group by.")

    parts = []
    for level in levels:
        grid = pd.DataFrame({x: x_seq})
        grid[group] = level
        for col, value in held.items():
            grid[col] = value
        piece = pd.concat(
            [grid[[x]].reset_index(drop=True), _prediction_band(model, grid, conf_level)],
            axis=1,
        )
        piece[group] = str(level)
        parts.append(piece)
    df = pd.concat(parts, ignore_index=True)

    p = ggplot(df, aes(x=x, y="fit", color=group))
    if band:
        p = p + geom_ribbon(aes(ymin="lower", ymax="upper", fill=group),
                            alpha=0.15, color=None)
    return (
        p
        + geom_line(size=0.9)
        + scale_colour_depictr(len(levels))
        + scale_fill_depictr(len(levels))
        + labs(x=x, y=_prediction_label(model),
               color=group, fill=group, title=title)
        + theme_depictr()
    )

compare_models

compare_models(models, intercept=False, conf_level=0.95, title=None, reference_line=None, exponentiate=False, labels=None, interaction='times')

Dodged forest comparing each coefficient across several models.

Each model contributes its estimates and confidence intervals (read with :func:depictr.models.tidy_estimates, so a fitted model or a tidy frame both work); the terms share a y-axis and the models are dodged apart and coloured, so a coefficient that shifts from one specification to the next is easy to spot.

The terms are labelled as in :func:depictr.coefficient_plot: a factor level names its variable and level (treatment: enhanced), read from the design of each fitted model, and other names are tidied with :func:depictr.format_terms. Terms that take the same label share a row, so stress and a brms-style b_stress are drawn as one term, as in the R twin.

Parameters:

Name Type Description Default
models dict

{name: fitted_model_or_tidy_frame}. The keys label and colour the models.

required
intercept bool

Whether to keep the intercept term.

False
conf_level float

Confidence level passed to :func:depictr.models.tidy_estimates, a single number strictly between 0 and 1.

0.95
title str

Plot title.

None
reference_line float

Position of the dashed vertical reference line. None, the default, puts it at 0, or at 1 when exponentiate is set. float("nan") leaves it out.

None
exponentiate bool

Whether to draw every model's exponentiated estimates and intervals on a log10 axis, as odds or rate ratios. A tidy frame among the models must hold estimates on the link scale, since it is exponentiated too. The axis is named as in :func:depictr.coefficient_plot when the fitted models agree on the ratio, and "exp(Estimate)" when they do not. Defaults to False.

False
labels (dict, list or 'raw')

Display labels for the terms. A dict maps raw term names to labels and leaves the other terms to the default labels, and a key that matches no term warns. A list gives one label per row, in the order the terms first appear across the models, which is the order they are drawn from the top, after the intercept is dropped unless intercept=True. Terms drawn on one row take one label. "raw" draws the names as the models give them.

None
interaction ('times', 'asterisk', 'colon', 'space')

How an interaction is written in the default labels, passed to :func:depictr.format_terms.

"times"

Returns:

Type Description
ggplot

The plot's data keeps the raw names in term and the drawn ones in label.

Examples:

>>> import depictr as dp
>>> import statsmodels.formula.api as smf
>>> cy = dp.crop_yield()
>>> reduced = smf.ols('Q("yield") ~ fertiliser', cy).fit()
>>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
>>> p = dp.compare_models({"Reduced": reduced, "Full": model})
Source code in src/depictr/predictions.py
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
def compare_models(models, intercept: bool = False, conf_level: float = 0.95,
                   title=None, reference_line=None, exponentiate: bool = False,
                   labels=None, interaction: str = "times"):
    """Dodged forest comparing each coefficient across several models.

    Each model contributes its estimates and confidence intervals (read with
    :func:`depictr.models.tidy_estimates`, so a fitted model or a tidy frame both
    work); the terms share a y-axis and the models are dodged apart and coloured,
    so a coefficient that shifts from one specification to the next is easy to
    spot.

    The terms are labelled as in :func:`depictr.coefficient_plot`: a factor
    level names its variable and level (``treatment: enhanced``), read from
    the design of each fitted model, and other names are tidied with
    :func:`depictr.format_terms`. Terms that take the same label share a row,
    so ``stress`` and a brms-style ``b_stress`` are drawn as one term, as in
    the R twin.

    Parameters
    ----------
    models : dict
        ``{name: fitted_model_or_tidy_frame}``. The keys label and colour the
        models.
    intercept : bool
        Whether to keep the intercept term.
    conf_level : float
        Confidence level passed to :func:`depictr.models.tidy_estimates`, a
        single number strictly between 0 and 1.
    title : str, optional
        Plot title.
    reference_line : float, optional
        Position of the dashed vertical reference line. ``None``, the default,
        puts it at 0, or at 1 when ``exponentiate`` is set. ``float("nan")``
        leaves it out.
    exponentiate : bool
        Whether to draw every model's exponentiated estimates and intervals on
        a log10 axis, as odds or rate ratios. A tidy frame among the models
        must hold estimates on the link scale, since it is exponentiated too.
        The axis is named as in :func:`depictr.coefficient_plot` when the
        fitted models agree on the ratio, and "exp(Estimate)" when they do
        not. Defaults to ``False``.
    labels : dict, list or "raw", optional
        Display labels for the terms. A dict maps raw term names to labels and
        leaves the other terms to the default labels, and a key that matches
        no term warns. A list gives one label per row, in the order the terms
        first appear across the models, which is the order they are drawn
        from the top, after the intercept is dropped unless
        ``intercept=True``. Terms drawn on one row take one label. ``"raw"``
        draws the names as the models give them.
    interaction : {"times", "asterisk", "colon", "space"}
        How an interaction is written in the default labels, passed to
        :func:`depictr.format_terms`.

    Returns
    -------
    plotnine.ggplot
        The plot's data keeps the raw names in ``term`` and the drawn ones in
        ``label``.

    Examples
    --------
    >>> import depictr as dp
    >>> import statsmodels.formula.api as smf
    >>> cy = dp.crop_yield()
    >>> reduced = smf.ols('Q("yield") ~ fertiliser', cy).fit()
    >>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
    >>> p = dp.compare_models({"Reduced": reduced, "Full": model})
    """
    from .models import (
        _check_exponentiate,
        _ratio_label,
        _reference_position,
        tidy_estimates,
    )

    if not isinstance(models, dict) or not models:
        raise ValueError("`models` must be a non-empty {name: model} dict.")
    _check_conf_level(conf_level)
    _check_exponentiate(exponentiate)
    interaction_separator(interaction)
    _check_labels(labels)
    line = _reference_position(reference_line, exponentiate)

    frames = []
    for name, model in models.items():
        est = tidy_estimates(model, conf_level=conf_level,
                             exponentiate=exponentiate).copy()
        est["model"] = str(name)
        frames.append(est)
    est = pd.concat(frames, ignore_index=True)
    _warn_unused_labels(est["term"], labels)

    # The ratio is named after the fitted models. Tidy frames carry no family,
    # and models of different families share only the neutral name.
    x_label = "Estimate"
    if exponentiate:
        named = {_ratio_label(m) for m in models.values()
                 if not isinstance(m, pd.DataFrame)}
        x_label = named.pop() if len(named) == 1 else "exp(Estimate)"

    # The factor labels of every fitted model, so a factor term that only a
    # later model holds is labelled too. Where two models hold the same
    # column, the first one's label is kept.
    auto = {}
    if not isinstance(labels, str):
        for model in models.values():
            for column, label in _design_labels(model, interaction).items():
                auto.setdefault(column, label)

    intercept_dropped = False
    if not intercept:
        is_intercept = est["term"].str.fullmatch(_INTERCEPT_PATTERN)
        intercept_dropped = bool(is_intercept.any())
        est = est[~is_intercept]
    if est.empty:
        raise ValueError("Nothing left to plot after dropping the intercept.")

    # Rows are aligned by label, as in the R twin, so terms of different models
    # that take the same label share one. A list gives one label per row, in
    # the order the terms first appear, and is matched to the default labels
    # in that order.
    positional = isinstance(labels, (list, tuple))
    drawn = _term_labels(est["term"], None if positional else labels, auto,
                         interaction)
    if positional:
        rows = list(dict.fromkeys(drawn))
        _check_label_count(labels, len(rows), "the models have",
                           "in the order they first appear", intercept_dropped)
        given = dict(zip(rows, (str(label) for label in labels), strict=True))
        drawn = [given[label] for label in drawn]
    est = est.assign(label=drawn)
    # Two terms of one model under one label would be drawn on top of each
    # other.
    for _, part in est.groupby("model", sort=False):
        _check_distinct(part["term"], part["label"])

    # First-seen order of terms and models. Terms run up the axis so the first
    # reads at the top, models keep their dict order in the legend.
    term_levels = list(dict.fromkeys(est["label"]))
    model_levels = [str(k) for k in models]
    est["model"] = pd.Categorical(est["model"], categories=model_levels, ordered=True)

    # Dodge the models by hand on a numeric y. position_dodge keys off the x
    # interval for a horizontal errorbar, so it cannot separate models that share
    # an x range; an explicit per-model offset within each term's slot does.
    n_models = len(model_levels)
    base_y = {term: len(term_levels) - 1 - i for i, term in enumerate(term_levels)}
    spread = 0.6  # total height a term's models occupy
    if n_models == 1:
        offset = {model_levels[0]: 0.0}
    else:
        offset = {m: (j / (n_models - 1) - 0.5) * spread
                  for j, m in enumerate(model_levels)}
    # strict=True: both are columns of the same frame, so unequal lengths cannot
    # happen and a silent truncation would misplace every later estimate.
    est["y"] = [base_y[t] + offset[m]
                for t, m in zip(est["label"], est["model"], strict=True)]
    # Listed from the bottom of the axis up, as coefficient_plot() lists them.
    est["label"] = pd.Categorical(est["label"], categories=term_levels[::-1],
                                  ordered=True)

    from plotnine import (
        element_blank,
        geom_errorbarh,
        geom_point,
        geom_vline,
        scale_x_log10,
        scale_y_continuous,
    )

    p = ggplot(est, aes(x="estimate", y="y", color="model"))
    if line is not None:
        p = p + geom_vline(xintercept=line, linetype="dashed", color="#9e9e9e")
    p = (
        p
        + geom_errorbarh(aes(xmin="conf_low", xmax="conf_high"),
                         height=0.0, size=0.7)
        + geom_point(size=2.4)
        + scale_colour_depictr(n_models, name="Model")
        + scale_y_continuous(
            breaks=[base_y[t] for t in term_levels],
            labels=term_levels,
            limits=(-0.5, len(term_levels) - 0.5),
        )
        + labs(x=x_label, y="", color="Model", title=title)
        + theme_depictr()
        + theme(panel_grid_major_y=element_blank())
    )
    return p + scale_x_log10() if exponentiate else p

random_effects_plot

random_effects_plot(model, title=None)

Caterpillar plot of the predicted random effects (BLUPs).

Each point is one group level's predicted random effect, the deviation of that group from the population fit. Levels are sorted by their effect, so the plot reads as a tilted ladder from the most negative to the most positive group, against a zero reference line. A group whose interval clears zero stands apart from the average; one straddling zero does not.

The points come straight from model.random_effects. When the fit exposes the conditional covariance of each group's effects (model.random_effects_cov), the diagonal gives a conditional standard error and the plot adds a 95% interval (the point estimate plus or minus 1.96 standard errors); without it the points are drawn on their own. If the model has more than one random-effect term per group (a random intercept and one or more random slopes), the terms are shown in separate panels.

Parameters:

Name Type Description Default
model statsmodels MixedLMResults

A fitted MixedLM result, as returned by MixedLM(...).fit() or smf.mixedlm(...).fit().

required
title str

Plot title.

None

Returns:

Type Description
ggplot
Notes

The interval is the conditional one around each predicted effect, not a confidence interval for a fixed parameter. It reflects how precisely that group's effect is pinned down given the fitted variance components.

Examples:

>>> import depictr as dp
>>> import statsmodels.formula.api as smf
>>> ld = dp.lexical_decision()
>>> mm = smf.mixedlm("RT ~ condition", ld, groups=ld["participant"]).fit()
>>> p = dp.random_effects_plot(mm, title="Participant intercepts")
Source code in src/depictr/mixed.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
def random_effects_plot(model, title=None):
    """Caterpillar plot of the predicted random effects (BLUPs).

    Each point is one group level's predicted random effect, the deviation of
    that group from the population fit. Levels are sorted by their effect, so the
    plot reads as a tilted ladder from the most negative to the most positive
    group, against a zero reference line. A group whose interval clears zero
    stands apart from the average; one straddling zero does not.

    The points come straight from ``model.random_effects``. When the fit exposes
    the conditional covariance of each group's effects
    (``model.random_effects_cov``), the diagonal gives a conditional standard
    error and the plot adds a 95% interval (the point estimate plus or minus
    1.96 standard errors); without it the points are drawn on their own. If the
    model has more than one random-effect term per group (a random intercept and
    one or more random slopes), the terms are shown in separate panels.

    Parameters
    ----------
    model : statsmodels MixedLMResults
        A fitted ``MixedLM`` result, as returned by ``MixedLM(...).fit()`` or
        ``smf.mixedlm(...).fit()``.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Notes
    -----
    The interval is the conditional one around each predicted effect, not a
    confidence interval for a fixed parameter. It reflects how precisely that
    group's effect is pinned down given the fitted variance components.

    Examples
    --------
    >>> import depictr as dp
    >>> import statsmodels.formula.api as smf
    >>> ld = dp.lexical_decision()
    >>> mm = smf.mixedlm("RT ~ condition", ld, groups=ld["participant"]).fit()
    >>> p = dp.random_effects_plot(mm, title="Participant intercepts")
    """
    _require_statsmodels()
    if not hasattr(model, "random_effects"):
        raise TypeError(
            "random_effects_plot needs a fitted statsmodels MixedLM result "
            "(one exposing `random_effects`)."
        )

    effects = model.random_effects
    if not effects:
        raise ValueError("The fitted model has no random effects to plot.")

    # Conditional covariance per group, when statsmodels provides it. The
    # diagonal of each group's matrix is the conditional variance of its BLUPs.
    cov = getattr(model, "random_effects_cov", None)

    rows = []
    for group, eff in effects.items():
        eff = pd.Series(eff)
        se = None
        if cov is not None and group in cov:
            var = np.diag(np.asarray(cov[group]))
            se = pd.Series(np.sqrt(var), index=eff.index)
        for term in eff.index:
            value = float(eff[term])
            this_se = float(se[term]) if se is not None else None
            rows.append({
                "group": str(group),
                "term": str(term),
                "effect": value,
                "low": value - 1.96 * this_se if this_se is not None else np.nan,
                "high": value + 1.96 * this_se if this_se is not None else np.nan,
            })
    df = pd.DataFrame(rows)
    has_interval = df["low"].notna().any()

    # One panel per random-effect term; sort the group axis within each term so
    # the most negative effect sits at the bottom after the flip.
    df = df.sort_values(["term", "effect"]).reset_index(drop=True)
    # A categorical group axis with a per-term ordering: build the level list in
    # plotting order. When there are several terms the same group appears under
    # each, so the order is taken from the (already sorted) appearance.
    order = list(dict.fromkeys(df["group"]))
    df["group"] = pd.Categorical(df["group"], categories=order, ordered=True)

    p = (
        ggplot(df, aes(x="group", y="effect"))
        + geom_hline(yintercept=0, linetype="dashed", color="#9e9e9e")
    )
    if has_interval:
        p = p + geom_errorbar(aes(ymin="low", ymax="high"), width=0,
                              color=depictr_brand(), size=0.7, na_rm=True)
    p = (
        p
        + geom_point(color=depictr_brand(), size=2.4)
        + coord_flip()
        + labs(x="", y="Predicted random effect", title=title)
        + theme_depictr()
    )

    n_terms = df["term"].nunique()
    if n_terms > 1:
        p = p + facet_wrap("term", scales="free_y")
    return p

posterior_plot

posterior_plot(draws, labels=None, title=None)

Forest plot of posterior (or bootstrap) draws.

Each parameter is one row: the median as a point, a thick inner band for the central 66% credible interval and a thin outer band for the central 95%. Both are equal-tailed (quantile) intervals, which leave the same share of the draws in either tail. The first parameter reads at the top.

Parameters:

Name Type Description Default
draws pandas.DataFrame or dict of array-like

The draws, one column (or dict entry) per parameter. A dict maps a parameter name to a 1-D array of draws; the arrays may differ in length.

required
labels dict

Remap raw parameter names to display names, {raw: shown}. Names not in the mapping are left unchanged; a key that matches no parameter warns, since a mistyped one would otherwise silently do nothing.

None
title str

Plot title.

None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> rng = np.random.default_rng(0)
>>> draws = {"stress": rng.normal(-0.4, 0.1, 500),
...           "sleep_hours": rng.normal(0.3, 0.1, 500)}
>>> p = dp.posterior_plot(draws)
Source code in src/depictr/posterior.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def posterior_plot(draws, labels=None, title=None):
    """Forest plot of posterior (or bootstrap) draws.

    Each parameter is one row: the median as a point, a thick inner band for the
    central 66% credible interval and a thin outer band for the central 95%.
    Both are equal-tailed (quantile) intervals, which leave the same share of
    the draws in either tail. The first parameter reads at the top.

    Parameters
    ----------
    draws : pandas.DataFrame or dict of array-like
        The draws, one column (or dict entry) per parameter. A dict maps a
        parameter name to a 1-D array of draws; the arrays may differ in length.
    labels : dict, optional
        Remap raw parameter names to display names, ``{raw: shown}``. Names not
        in the mapping are left unchanged; a key that matches no parameter
        warns, since a mistyped one would otherwise silently do nothing.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> rng = np.random.default_rng(0)
    >>> draws = {"stress": rng.normal(-0.4, 0.1, 500),
    ...           "sleep_hours": rng.normal(0.3, 0.1, 500)}
    >>> p = dp.posterior_plot(draws)
    """
    est = _summarise_draws(draws, labels=labels)
    # Reverse so the first parameter sits at the top once the axes are flipped.
    levels = list(est["term"])[::-1]
    est = est.assign(term=pd.Categorical(est["term"], categories=levels,
                                         ordered=True))
    # geom_linerange is vertical (x, ymin, ymax), so build it upright on the
    # estimate scale and flip to a horizontal forest at the end.
    return (
        ggplot(est, aes(x="term"))
        + geom_hline(yintercept=0, linetype="dashed", color="#9e9e9e")
        + geom_linerange(aes(ymin="outer_low", ymax="outer_high"),
                         color=BRAND, size=0.8)
        + geom_linerange(aes(ymin="inner_low", ymax="inner_high"),
                         color=BRAND, size=1.8)
        + geom_point(aes(y="median"), color=BRAND, size=2.8)
        + coord_flip()
        + labs(x="", y="Estimate", title=title)
        + theme_depictr()
    )

frequentist_bayesian_plot

frequentist_bayesian_plot(frequentist, bayesian, title=None)

Overlay a frequentist estimate against a Bayesian posterior per term.

For each term the two sources share a row, offset slightly so they do not overlap. The frequentist side shows the point estimate with its confidence interval; the Bayesian side shows the posterior median with the inner 66% and outer 95% credible intervals. The sources are told apart by colour (brand blue for frequentist, the palette's orange for Bayesian).

Only terms present in both sources are drawn. The frequentist confidence level is whatever :func:depictr.models.tidy_estimates reads from the model (95% for a fitted statsmodels result), matching the Bayesian outer band.

Parameters:

Name Type Description Default
frequentist statsmodels results object or pandas.DataFrame

A fitted model or a tidy estimate frame, as accepted by :func:depictr.models.tidy_estimates.

required
bayesian pandas.DataFrame or dict of array-like

Posterior draws, one column (or dict entry) per term, as accepted by :func:posterior_plot.

required
title str

Plot title.

None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> import statsmodels.formula.api as smf
>>> cy = dp.crop_yield()
>>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
>>> rng = np.random.default_rng(0)
>>> draws = {t: rng.normal(model.params[t], model.bse[t], 500)
...          for t in model.params.index if t != "Intercept"}
>>> p = dp.frequentist_bayesian_plot(model, draws)
Source code in src/depictr/posterior.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def frequentist_bayesian_plot(frequentist, bayesian, title=None):
    """Overlay a frequentist estimate against a Bayesian posterior per term.

    For each term the two sources share a row, offset slightly so they do not
    overlap. The frequentist side shows the point estimate with its confidence
    interval; the Bayesian side shows the posterior median with the inner 66% and
    outer 95% credible intervals. The sources are told apart by colour (brand
    blue for frequentist, the palette's orange for Bayesian).

    Only terms present in both sources are drawn. The frequentist confidence
    level is whatever :func:`depictr.models.tidy_estimates` reads from the model
    (95% for a fitted statsmodels result), matching the Bayesian outer band.

    Parameters
    ----------
    frequentist : statsmodels results object or pandas.DataFrame
        A fitted model or a tidy estimate frame, as accepted by
        :func:`depictr.models.tidy_estimates`.
    bayesian : pandas.DataFrame or dict of array-like
        Posterior draws, one column (or dict entry) per term, as accepted by
        :func:`posterior_plot`.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> import statsmodels.formula.api as smf
    >>> cy = dp.crop_yield()
    >>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
    >>> rng = np.random.default_rng(0)
    >>> draws = {t: rng.normal(model.params[t], model.bse[t], 500)
    ...          for t in model.params.index if t != "Intercept"}
    >>> p = dp.frequentist_bayesian_plot(model, draws)
    """
    from .models import tidy_estimates

    freq = tidy_estimates(frequentist).rename(columns={
        "estimate": "median", "conf_low": "outer_low", "conf_high": "outer_high",
    })
    # The frequentist CI has no inner band; leave it absent for that source.
    freq = freq.assign(inner_low=np.nan, inner_high=np.nan,
                       source="Frequentist")
    bayes = _summarise_draws(bayesian).assign(source="Bayesian")

    shared = [t for t in freq["term"] if t in set(bayes["term"])]
    if not shared:
        raise ValueError(
            "the frequentist and Bayesian inputs share no term names."
        )
    both = pd.concat([freq[freq["term"].isin(shared)],
                      bayes[bayes["term"].isin(shared)]], ignore_index=True)

    # The first shared term sits at the top once flipped. The sources take the
    # first two palette colours, the brand blue and the orange.
    both = both.assign(
        term=pd.Categorical(both["term"], categories=shared[::-1], ordered=True),
        source=pd.Categorical(both["source"],
                              categories=["Frequentist", "Bayesian"]),
    )
    dodge = position_dodge(width=0.5)
    return (
        ggplot(both, aes(x="term", color="source"))
        + geom_hline(yintercept=0, linetype="dashed", color="#9e9e9e")
        + geom_linerange(aes(ymin="outer_low", ymax="outer_high"),
                         position=dodge, size=0.8)
        + geom_linerange(aes(ymin="inner_low", ymax="inner_high"),
                         position=dodge, size=1.8, na_rm=True)
        + geom_point(aes(y="median"), position=dodge, size=2.8)
        + scale_colour_depictr(name="Source")
        + coord_flip()
        + labs(x="", y="Estimate", title=title)
        + theme_depictr()
    )

power_curve_plot

power_curve_plot(data, n=None, power=None, group=None, title=None, target=_TARGET_POWER, lower=None, upper=None, ribbon=True, y_lab=None, solved=None)

Line plot of statistical power against sample size.

Reads a precomputed tidy table and draws power as a function of sample size, with a dashed reference line at the target power (0.8 by default). Where the curve crosses the line is the sample size that reaches adequate power.

A table may hold several curves, one per effect or design, stacked by row. Each is drawn as its own line, told apart by colour and line type. The tables pilotr returns are read as they are: power_curve_mixed() gives n_subject, effect, power, power_lo and power_hi, and precision_curve() gives n_subject, param and the decision probabilities p_meaningful and p_equivalent with their own _lo and _hi bounds, which power can name. The sample size that pilotr's target_n() solves from such a curve, and its interval, can be marked on the plot with solved.

Parameters:

Name Type Description Default
data pandas.DataFrame or list of dict

A tidy table with one row per sample size (per curve, if there are several). A list of dicts, one per row, is read with :class:pandas.DataFrame.

required
n str

Name of the sample-size column. By default, the first of n, nlevels, x, sample_size and n_subject present.

None
power str

Name of the power column, on the 0-1 scale. By default, the first of power, mean and p present. Any proportion works, such as pilotr's p_meaningful.

None
group str

A column that separates several curves. Each level is drawn as its own line, in its own colour and line type. By default, an effect, param or term column with more than one level is used, with a warning. Rows that share a sample size with no such column raise a ValueError, because joining them would draw a zig-zag.

None
title str

Plot title.

None
target float

Target power, drawn as a horizontal reference line. None omits it.

_TARGET_POWER
lower str

Columns holding the lower and upper confidence limits. Name both or neither. By default, <power>_lo and <power>_hi are used, then lower/upper, ci_low/ci_high or conf.low/conf.high.

None
upper str

Columns holding the lower and upper confidence limits. Name both or neither. By default, <power>_lo and <power>_hi are used, then lower/upper, ci_low/ci_high or conf.low/conf.high.

None
ribbon bool

Whether to draw the confidence band as a shaded ribbon (True) or as error bars (False).

True
y_lab str

Y-axis label. By default, "Power" when the power column is one of the default names, and the column's name otherwise.

None
solved dict or DataFrame

A sample size solved from the curve, with its confidence interval: a dict with numeric value, lo and hi, as pilotr's target_n() and solve_curve() return, or a one-row DataFrame with the same columns. Other keys are ignored. The solved value is drawn as a dashed vertical line and its interval as a grey band behind the curve. An interval that reaches past the swept sizes widens the x-axis to show it. Anything else raises ValueError.

None

Returns:

Type Description
ggplot
References

Cohen, J. (1988). Statistical power analysis for the behavioral sciences (2nd ed.). Lawrence Erlbaum Associates.

Examples:

>>> import depictr as dp
>>> import pandas as pd
>>> from statsmodels.stats.power import TTestIndPower
>>> analysis = TTestIndPower()
>>> power = pd.DataFrame({"n": range(25, 425, 25)})
>>> power["power"] = [analysis.power(effect_size=0.5, nobs1=n, alpha=0.05)
...                   for n in power["n"]]
>>> p = dp.power_curve_plot(power)

The sample size at which the curve reaches 80% power, with its interval, in the form pilotr's target_n() returns. The interval here is illustrative:

>>> p = dp.power_curve_plot(power, solved={"value": 64, "lo": 57, "hi": 72})

Two curves in one table, laid out as pilotr's power_curve_mixed() returns them, one row per sample size and effect:

>>> rows = [
...     {"n_subject": n, "effect": e, "power": pw,
...      "power_lo": pw - 0.08, "power_hi": pw + 0.08}
...     for n, e, pw in [(20, "condition", 0.31), (20, "frequency", 0.12),
...                      (40, "condition", 0.58), (40, "frequency", 0.21),
...                      (60, "condition", 0.78), (60, "frequency", 0.33)]
... ]
>>> p = dp.power_curve_plot(rows, group="effect")
Source code in src/depictr/power.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
def power_curve_plot(data, n=None, power=None, group=None, title=None,
                     target=_TARGET_POWER, lower=None, upper=None,
                     ribbon=True, y_lab=None, solved=None):
    """Line plot of statistical power against sample size.

    Reads a precomputed tidy table and draws power as a function of sample size,
    with a dashed reference line at the target power (0.8 by default). Where the
    curve crosses the line is the sample size that reaches adequate power.

    A table may hold several curves, one per effect or design, stacked by row.
    Each is drawn as its own line, told apart by colour and line type. The
    tables pilotr returns are read as they are: ``power_curve_mixed()`` gives
    ``n_subject``, ``effect``, ``power``, ``power_lo`` and ``power_hi``, and
    ``precision_curve()`` gives ``n_subject``, ``param`` and the decision
    probabilities ``p_meaningful`` and ``p_equivalent`` with their own ``_lo``
    and ``_hi`` bounds, which ``power`` can name. The sample size that pilotr's
    ``target_n()`` solves from such a curve, and its interval, can be marked on
    the plot with ``solved``.

    Parameters
    ----------
    data : pandas.DataFrame or list of dict
        A tidy table with one row per sample size (per curve, if there are
        several). A list of dicts, one per row, is read with
        :class:`pandas.DataFrame`.
    n : str, optional
        Name of the sample-size column. By default, the first of ``n``,
        ``nlevels``, ``x``, ``sample_size`` and ``n_subject`` present.
    power : str, optional
        Name of the power column, on the 0-1 scale. By default, the first of
        ``power``, ``mean`` and ``p`` present. Any proportion works, such as
        pilotr's ``p_meaningful``.
    group : str, optional
        A column that separates several curves. Each level is drawn as its own
        line, in its own colour and line type. By default, an ``effect``,
        ``param`` or ``term`` column with more than one level is used, with a
        warning. Rows that share a sample size with no such column raise a
        ``ValueError``, because joining them would draw a zig-zag.
    title : str, optional
        Plot title.
    target : float, optional
        Target power, drawn as a horizontal reference line. ``None`` omits it.
    lower, upper : str, optional
        Columns holding the lower and upper confidence limits. Name both or
        neither. By default, ``<power>_lo`` and ``<power>_hi`` are used, then
        ``lower``/``upper``, ``ci_low``/``ci_high`` or ``conf.low``/``conf.high``.
    ribbon : bool
        Whether to draw the confidence band as a shaded ribbon (``True``) or as
        error bars (``False``).
    y_lab : str, optional
        Y-axis label. By default, "Power" when the power column is one of the
        default names, and the column's name otherwise.
    solved : dict or pandas.DataFrame, optional
        A sample size solved from the curve, with its confidence interval: a
        dict with numeric ``value``, ``lo`` and ``hi``, as pilotr's
        ``target_n()`` and ``solve_curve()`` return, or a one-row DataFrame
        with the same columns. Other keys are ignored. The solved value is
        drawn as a dashed vertical line and its interval as a grey band behind
        the curve. An interval that reaches past the swept sizes widens the
        x-axis to show it. Anything else raises ``ValueError``.

    Returns
    -------
    plotnine.ggplot

    References
    ----------
    Cohen, J. (1988). Statistical power analysis for the behavioral sciences
    (2nd ed.). Lawrence Erlbaum Associates.

    Examples
    --------
    >>> import depictr as dp
    >>> import pandas as pd
    >>> from statsmodels.stats.power import TTestIndPower
    >>> analysis = TTestIndPower()
    >>> power = pd.DataFrame({"n": range(25, 425, 25)})
    >>> power["power"] = [analysis.power(effect_size=0.5, nobs1=n, alpha=0.05)
    ...                   for n in power["n"]]
    >>> p = dp.power_curve_plot(power)

    The sample size at which the curve reaches 80% power, with its interval,
    in the form pilotr's ``target_n()`` returns. The interval here is
    illustrative:

    >>> p = dp.power_curve_plot(power, solved={"value": 64, "lo": 57, "hi": 72})

    Two curves in one table, laid out as pilotr's ``power_curve_mixed()``
    returns them, one row per sample size and effect:

    >>> rows = [
    ...     {"n_subject": n, "effect": e, "power": pw,
    ...      "power_lo": pw - 0.08, "power_hi": pw + 0.08}
    ...     for n, e, pw in [(20, "condition", 0.31), (20, "frequency", 0.12),
    ...                      (40, "condition", 0.58), (40, "frequency", 0.21),
    ...                      (60, "condition", 0.78), (60, "frequency", 0.33)]
    ... ]
    >>> p = dp.power_curve_plot(rows, group="effect")
    """
    marks = _solved_marks(solved)
    if not isinstance(data, pd.DataFrame):
        data = pd.DataFrame(data)

    n = _check_column(data, n, "n") or _pick(data, _N_CANDIDATES)
    power = _check_column(data, power, "power") or _pick(data, _POWER_CANDIDATES)
    if n is None or power is None:
        raise KeyError("Could not find sample-size and power columns in the "
                       "data. Name them with `n` and `power`.")

    _check_column(data, lower, "lower")
    _check_column(data, upper, "upper")
    if (lower is None) != (upper is None):
        raise ValueError("Name both `lower` and `upper`, or neither.")
    if lower is None:
        # The bounds of the chosen power column come first, so that a pilotr
        # precision table banded by p_meaningful_lo is not given another
        # column's bounds.
        lower = _pick(data, (f"{power}_lo", *_LOWER_CANDIDATES))
        upper = _pick(data, (f"{power}_hi", *_UPPER_CANDIDATES))
    has_band = lower is not None and upper is not None

    group = _check_column(data, group, "group")
    if group is None:
        # A column with a single level names the one curve the table holds and
        # separates nothing.
        multi = [c for c in _GROUP_CANDIDATES
                 if c in data.columns and data[c].dropna().nunique() > 1]
        if multi:
            group = multi[0]
            warnings.warn(
                f"power_curve_plot(): drawing one curve per level of `{group}`, "
                f"with sample size from `{n}` and power from `{power}`. Name "
                f"the column with `group` to silence this warning.",
                UserWarning,
                stacklevel=2,
            )

    rows = data[data[n].notna()]
    if group is None:
        if rows[n].duplicated().any():
            raise ValueError("Several rows share a sample size; name the column "
                             "that separates the curves with `group`.")
    else:
        if rows.duplicated(subset=[n, group]).any():
            raise ValueError("Several rows of one curve share a sample size. "
                             "Summarise them, or name a column that separates "
                             "them with `group`.")
        # Levels in their order of appearance, as in the R twin, and drawn as
        # discrete even when the column holds numbers.
        data = data.assign(**{group: pd.Categorical(
            data[group], categories=pd.unique(data[group].dropna()))})

    if y_lab is None:
        y_lab = "Power" if power in _POWER_CANDIDATES else power

    mapping = (aes(x=n, y=power, color=group, linetype=group, group=group)
               if group else aes(x=n, y=power))
    p = ggplot(data, mapping)

    if marks is not None:
        # First, so that the interval lies behind the confidence band and the
        # curve. The fill and opacity are those of the R twin's ROPE band in
        # posterior_plot().
        p = p + annotate("rect", xmin=marks["lo"], xmax=marks["hi"],
                         ymin=-math.inf, ymax=math.inf, fill=REFERENCE,
                         alpha=0.15)

    if has_band:
        if ribbon and group:
            # Translucent, so that overlapping bands stay visible, and without
            # the line type, which belongs to the curve.
            p = (p + geom_ribbon(aes(ymin=lower, ymax=upper, fill=group),
                                 alpha=0.15, color="none", linetype="solid",
                                 show_legend=False)
                 + scale_fill_depictr(guide=None))
        elif ribbon:
            p = p + geom_ribbon(aes(ymin=lower, ymax=upper), fill=_BAND_FILL)
        elif group:
            p = p + geom_linerange(aes(ymin=lower, ymax=upper),
                                   linetype="solid")
        else:
            p = p + geom_linerange(aes(ymin=lower, ymax=upper),
                                   color=_BAR_COLOUR)

    if target is not None and not pd.isna(target):
        p = p + geom_hline(yintercept=target, linetype="dashed",
                           color="#9e9e9e")
    if marks is not None:
        p = p + geom_vline(xintercept=marks["value"], linetype="dashed",
                           color=REFERENCE)
    if group:
        p = (p + geom_line(size=0.9) + geom_point(size=2)
             + scale_colour_depictr())
    else:
        p = (p + geom_line(color=BRAND, size=0.9)
             + geom_point(color=BRAND, size=2))
    return (
        p
        + scale_y_continuous(limits=(0, 1))
        + labs(x="Sample size", y=y_lab, color=group, linetype=group,
               title=title)
        + theme_depictr()
    )

Diagnostics

Residual and influence diagnostics, and a one-figure model report.

These plots are shown, with the code that produced them, on the model estimates and diagnostics page of the gallery.

qq_plot

qq_plot(model, title=None)

Normal quantile-quantile plot of the residuals, with a reference line.

The sample residual quantiles are plotted against the matching standard-normal quantiles. Points near the line mean the residuals are close to normal; a systematic bend away from it flags skew or heavy tails. The line is drawn through the first and third quartiles, the robust choice R's qqline uses, so a few outliers do not tilt it.

The residuals of a linear model should follow the line. The studentised residuals of a non-Gaussian GLM are not expected to, even when the model is correctly specified: those of a binary model take only two values at each fitted value. A bend then says little about the fit. For a GLM, :func:depictr.residual_diagnostics_plot and :func:depictr.model_report plot randomised quantile residuals (Dunn & Smyth, 1996), which are standard normal under a correct model.

Parameters:

Name Type Description Default
model statsmodels results object or array-like

A fitted OLS/GLM result, from which the internally studentised residuals are read (the raw residuals if studentised ones are not available), or a 1-D array of residuals to plot directly.

required
title str

Plot title.

None

Returns:

Type Description
ggplot
References

Dunn, P. K., & Smyth, G. K. (1996). Randomized quantile residuals. Journal of Computational and Graphical Statistics, 5(3), 236-244. https://doi.org/10.1080/10618600.1996.10474708

Examples:

>>> import depictr as dp
>>> import statsmodels.formula.api as smf
>>> cy = dp.crop_yield()
>>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
>>> p = dp.qq_plot(model)
Source code in src/depictr/diagnostics.py
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
def qq_plot(model, title=None):
    """Normal quantile-quantile plot of the residuals, with a reference line.

    The sample residual quantiles are plotted against the matching standard-normal
    quantiles. Points near the line mean the residuals are close to normal; a
    systematic bend away from it flags skew or heavy tails. The line is drawn
    through the first and third quartiles, the robust choice R's ``qqline`` uses,
    so a few outliers do not tilt it.

    The residuals of a linear model should follow the line. The studentised
    residuals of a non-Gaussian GLM are not expected to, even when the model
    is correctly specified: those of a binary model take only two values at
    each fitted value. A bend then says little about the fit. For a GLM,
    :func:`depictr.residual_diagnostics_plot` and :func:`depictr.model_report`
    plot randomised quantile residuals (Dunn & Smyth, 1996), which are standard
    normal under a correct model.

    Parameters
    ----------
    model : statsmodels results object or array-like
        A fitted OLS/GLM result, from which the internally studentised residuals
        are read (the raw residuals if studentised ones are not available), or a
        1-D array of residuals to plot directly.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    References
    ----------
    Dunn, P. K., & Smyth, G. K. (1996). Randomized quantile residuals.
    Journal of Computational and Graphical Statistics, 5(3), 236-244.
    https://doi.org/10.1080/10618600.1996.10474708

    Examples
    --------
    >>> import depictr as dp
    >>> import statsmodels.formula.api as smf
    >>> cy = dp.crop_yield()
    >>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
    >>> p = dp.qq_plot(model)
    """
    from scipy import stats

    if _is_fitted_model(model):
        resid = _studentised_residuals(model)
    else:
        resid = np.asarray(model, dtype=float).ravel()

    # probplot returns the theoretical (osm) and ordered sample (osr) quantiles.
    theoretical, ordered = stats.probplot(resid, dist="norm", fit=False)
    df = pd.DataFrame({"theoretical": theoretical, "sample": ordered})

    # Reference line through the quartiles, matching R's qqline.
    q_theory = stats.norm.ppf([0.25, 0.75])
    q_sample = np.percentile(resid, [25, 75])
    slope = (q_sample[1] - q_sample[0]) / (q_theory[1] - q_theory[0])
    intercept = q_sample[0] - slope * q_theory[0]

    return (
        ggplot(df, aes("theoretical", "sample"))
        + geom_abline(intercept=intercept, slope=slope,
                      linetype="dashed", color="#9e9e9e")
        + geom_point(color=BRAND, size=2, alpha=0.7)
        + labs(x="Theoretical quantiles", y="Sample quantiles", title=title)
        + theme_depictr()
    )

influence_plot

influence_plot(model, title=None)

Bubble plot of studentised residuals against leverage.

Each point is one observation; the bubble area is proportional to its Cook's distance, the standard measure of how much the fit would move if the point were dropped (Cook, 1977). Points to the right have high leverage (an unusual predictor combination), points far above or below zero are poorly fitted, and large bubbles are the ones that combine both into real influence.

Parameters:

Name Type Description Default
model statsmodels results object

A fitted OLS/GLM result.

required
title str

Plot title.

None

Returns:

Type Description
ggplot
References

Cook, R. D. (1977). Detection of influential observation in linear regression. Technometrics, 19(1), 15-18. https://doi.org/10.1080/00401706.1977.10489493

Examples:

>>> import depictr as dp
>>> import statsmodels.formula.api as smf
>>> cy = dp.crop_yield()
>>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
>>> p = dp.influence_plot(model)
Source code in src/depictr/diagnostics.py
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
def influence_plot(model, title=None):
    """Bubble plot of studentised residuals against leverage.

    Each point is one observation; the bubble area is proportional to its Cook's
    distance, the standard measure of how much the fit would move if the point
    were dropped (Cook, 1977). Points to the right have high leverage (an unusual
    predictor combination), points far above or below zero are poorly fitted, and
    large bubbles are the ones that combine both into real influence.

    Parameters
    ----------
    model : statsmodels results object
        A fitted OLS/GLM result.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    References
    ----------
    Cook, R. D. (1977). Detection of influential observation in linear
    regression. Technometrics, 19(1), 15-18.
    https://doi.org/10.1080/00401706.1977.10489493

    Examples
    --------
    >>> import depictr as dp
    >>> import statsmodels.formula.api as smf
    >>> cy = dp.crop_yield()
    >>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
    >>> p = dp.influence_plot(model)
    """
    _require_statsmodels()
    if not _is_fitted_model(model):
        raise TypeError("influence_plot needs a fitted statsmodels result.")

    infl = model.get_influence()
    df = pd.DataFrame({
        "leverage": np.asarray(infl.hat_matrix_diag),
        "studentised": _studentised_residuals(model),
        "cooks": np.asarray(infl.cooks_distance[0]),
    })
    return (
        ggplot(df, aes("leverage", "studentised", size="cooks"))
        + geom_hline(yintercept=0, linetype="dashed", color="#9e9e9e")
        + geom_point(color=BRAND, alpha=0.5)
        + scale_size_area(max_size=10, name="Cook's distance")
        + labs(x="Leverage (hat value)", y="Studentised residual", title=title)
        + theme_depictr()
    )

vif_plot

vif_plot(model, threshold=5, title=None)

Horizontal bar chart of the variance inflation factor of each model term.

The variance inflation factor (VIF) measures how much collinearity with the other predictors inflates the variance of a coefficient. A VIF of 1 means no collinearity, and the reference line marks threshold, 5 by default, a common rule of thumb above which collinearity is treated as a concern.

A term that spans several design columns, such as a factor with more than two levels, gets one generalised VIF, the GVIF of Fox and Monette (1992). Let R be the correlation matrix of the coefficient estimates other than the intercept, R11 its block for the term and R22 the block for the other terms. Then GVIF = det(R11) det(R22) / det(R). Unlike the VIFs of a factor's separate dummy columns, it does not depend on which level is the reference. For a term of one column, it is the ordinary VIF. When any term spans several columns, the bars show GVIF^(1/(2 df)) and the line moves to the square root of threshold. GVIF^(1/(2 df)) puts terms of different sizes on one scale, and for a single column it is the square root of the VIF.

The correlations come from the fit's unscaled coefficient covariance, normalized_cov_params, so a weighted fit or a GLM is measured with the weights of its fit. For an unweighted linear model with an intercept, they give the values the formula gives with R the correlation matrix of the predictor columns, as Fox and Monette state it. The values are those of the R twin and of R's car::vif(). A Probit result differs slightly, because statsmodels takes its covariance from the observed information matrix, where R's glm() uses the expected one. A GLM with a probit link gives R's values.

The columns are grouped into terms by the model's formula, whether statsmodels built it with patsy or with formulaic. A model fitted from arrays has no formula, so each column is a term of its own, and the dummy columns of a factor are not grouped. A model whose coefficients are aliased has no VIF, so it raises ValueError naming the terms they belong to, as does a model with fewer than two predictor columns. A pandas categorical keeps a category that no row takes, after rows are filtered out for instance. Such a category fills a column of zeros, which counts as aliased, so drop it with Series.cat.remove_unused_categories() before the fit. R's lm() drops such a level itself. A model without an intercept warns, because its VIFs then come from uncentred columns.

Parameters:

Name Type Description Default
model statsmodels results object

A fitted OLS, WLS, GLM, Logit or Probit result with two or more predictor columns.

required
threshold float

The VIF taken as the rule of thumb, drawn as the reference line. On the GVIF^(1/(2 df)) scale, it is drawn at sqrt(threshold).

5
title str

Plot title.

None

Returns:

Type Description
ggplot

Its data hold one row per term, with the columns term, df, gvif and vif, the value drawn.

References

Fox, J., & Monette, G. (1992). Generalized collinearity diagnostics. Journal of the American Statistical Association, 87(417), 178-183. https://doi.org/10.1080/01621459.1992.10475190

Examples:

>>> import depictr as dp
>>> import statsmodels.formula.api as smf
>>> cy = dp.crop_yield()
>>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
>>> p = dp.vif_plot(model)

A factor with several levels gets one generalised VIF:

>>> ws = dp.wellbeing_survey()
>>> fit = smf.ols("life_satisfaction ~ stress + region + education", ws).fit()
>>> p = dp.vif_plot(fit)
Source code in src/depictr/diagnostics.py
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
def vif_plot(model, threshold=5, title=None):
    """Horizontal bar chart of the variance inflation factor of each model term.

    The variance inflation factor (VIF) measures how much collinearity with
    the other predictors inflates the variance of a coefficient. A VIF of 1
    means no collinearity, and the reference line marks ``threshold``, 5 by
    default, a common rule of thumb above which collinearity is treated as a
    concern.

    A term that spans several design columns, such as a factor with more than
    two levels, gets one generalised VIF, the GVIF of Fox and Monette (1992).
    Let R be the correlation matrix of the coefficient estimates other than
    the intercept, R11 its block for the term and R22 the block for the other
    terms. Then GVIF = det(R11) det(R22) / det(R). Unlike the VIFs of a
    factor's separate dummy columns, it does not depend on which level is the
    reference. For a term of one column, it is the ordinary VIF. When any term
    spans several columns, the bars show GVIF^(1/(2 df)) and the line moves to
    the square root of ``threshold``. GVIF^(1/(2 df)) puts terms of different
    sizes on one scale, and for a single column it is the square root of the
    VIF.

    The correlations come from the fit's unscaled coefficient covariance,
    ``normalized_cov_params``, so a weighted fit or a GLM is measured with the
    weights of its fit. For an unweighted linear model with an intercept, they
    give the values the formula gives with R the correlation matrix of the
    predictor columns, as Fox and Monette state it. The values are those of
    the R twin and of R's ``car::vif()``. A Probit result differs slightly,
    because statsmodels takes its covariance from the observed information
    matrix, where R's ``glm()`` uses the expected one. A GLM with a probit
    link gives R's values.

    The columns are grouped into terms by the model's formula, whether
    statsmodels built it with patsy or with formulaic. A model fitted from
    arrays has no formula, so each column is a term of its own, and the dummy
    columns of a factor are not grouped. A model whose coefficients are
    aliased has no VIF, so it raises ``ValueError`` naming the terms they
    belong to, as does a model with fewer than two predictor columns. A pandas
    categorical keeps a category that no row takes, after rows are filtered
    out for instance. Such a category fills a column of zeros, which counts as
    aliased, so drop it with ``Series.cat.remove_unused_categories()`` before
    the fit. R's ``lm()`` drops such a level itself. A model without an
    intercept warns, because its VIFs then come from uncentred columns.

    Parameters
    ----------
    model : statsmodels results object
        A fitted OLS, WLS, GLM, Logit or Probit result with two or more
        predictor columns.
    threshold : float, default 5
        The VIF taken as the rule of thumb, drawn as the reference line. On
        the GVIF^(1/(2 df)) scale, it is drawn at ``sqrt(threshold)``.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot
        Its ``data`` hold one row per term, with the columns ``term``, ``df``,
        ``gvif`` and ``vif``, the value drawn.

    References
    ----------
    Fox, J., & Monette, G. (1992). Generalized collinearity diagnostics.
    Journal of the American Statistical Association, 87(417), 178-183.
    https://doi.org/10.1080/01621459.1992.10475190

    Examples
    --------
    >>> import depictr as dp
    >>> import statsmodels.formula.api as smf
    >>> cy = dp.crop_yield()
    >>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
    >>> p = dp.vif_plot(model)

    A factor with several levels gets one generalised VIF:

    >>> ws = dp.wellbeing_survey()
    >>> fit = smf.ols("life_satisfaction ~ stress + region + education", ws).fit()
    >>> p = dp.vif_plot(fit)
    """
    _require_statsmodels()
    if not _has_coefficient_covariance(model):
        raise TypeError("vif_plot needs a fitted statsmodels result.")
    if (isinstance(threshold, bool) or not isinstance(threshold, numbers.Real)
            or not math.isfinite(threshold) or threshold <= 0):
        raise ValueError("`threshold` must be a single positive number.")

    res = _gvif_terms(model)
    # A term of one column has an ordinary VIF, drawn against the threshold
    # itself. A term of several columns has no scalar VIF. When there is one,
    # every term is drawn as GVIF^(1/(2 df)) and the line moves to the square
    # root of the threshold, as in the R twin.
    if (res["df"] > 1).any():
        value, line = res["gvif_adj"], math.sqrt(threshold)
        axis = r"$\mathrm{GVIF}^{1/(2\,\mathrm{df})}$"
    else:
        value, line, axis = res["gvif"], threshold, "Variance inflation factor"
    df = res[["term", "df", "gvif"]].assign(vif=value)
    df = df.sort_values("vif", kind="stable")
    # Lock the order so the largest VIF reads at the top after the flip.
    df["term"] = pd.Categorical(df["term"], categories=list(df["term"]), ordered=True)

    return (
        ggplot(df, aes("term", "vif"))
        + geom_col(fill=BRAND, width=0.7)
        + geom_hline(yintercept=line, color="#666666")
        + coord_flip()
        + labs(x="", y=axis, title=title)
        + theme_depictr(grid="x")
    )

binned_residual_plot

binned_residual_plot(model, n_bins=None, title=None, band='model')

Binned residual plot for a binomial model (Gelman & Hill, 2007).

Raw residuals from a logistic model are uninformative point by point, so the fitted probabilities are split into equal-count bins and the mean residual is plotted against the mean fitted value in each bin. The bins are the R twin's. Rows are ranked by their fitted probability with ties in row order, and the ranks are cut at multiples of n / n_bins. A row with zero weight takes no part in the fit and is left out.

The grey band is plus or minus two standard errors of a bin's mean residual. By default (band="model"), they are the standard errors the fitted model implies. The model gives a row with fitted probability p_i over m_i trials, taken as a proportion, the variance scale * p_i * (1 - p_i) / m_i. Taking this as the variance of the row's residual, the band gives the mean of the n_b residuals in a bin the standard error sqrt(sum of their variances) / n_b. The fit absorbs a little of each row's variance, so a residual varies slightly less than this and the band errs on the wide side. Under a well-fitting model, about 95% of the points fall inside the band. A run of points outside it, or a clear trend in them, points to a missing predictor or the wrong functional form.

The scale is 1 unless the GLM was fitted with an estimated one, the counterpart of the dispersion of R's quasibinomial family. statsmodels' Pearson estimate, fit(scale="X2"), leaves out the trials of a two-column (successes, failures) response, so for such a fit the scale is recomputed as pearson_chi2 / df_resid, which counts them, as R does.

band="empirical" takes the standard error from the residuals in the bin, as their standard deviation over sqrt(n_b), the band of R's arm::binnedplot(). It collapses in a bin whose outcomes are all alike, such as a bin without events, and so flags such bins even when the model is right.

Parameters:

Name Type Description Default
model statsmodels results object

A fitted binomial GLM (logistic regression), or a Logit or Probit result, whose fitted probabilities are used.

required
n_bins int

Number of bins, at least 2. Defaults to the square root of the sample size, rounded down and kept between 2 and half the sample size, the rule of thumb in Gelman & Hill.

None
title str

Plot title.

None
band ('model', 'empirical')

Where the standard errors come from: the variance the fitted model implies for each residual, or the spread of the residuals in each bin.

"model"

Returns:

Type Description
ggplot

Its data hold one row per bin, with the columns fitted, resid, n, se2 (the band's half-width), outside (whether the point lies beyond the band), lower and upper.

References

Gelman, A., & Hill, J. (2007). Data analysis using regression and multilevel/hierarchical models. Cambridge University Press.

Examples:

>>> import depictr as dp
>>> import statsmodels.api as sm
>>> import statsmodels.formula.api as smf
>>> ct = dp.clinical_trial()
>>> glm = smf.glm("adverse_event ~ biomarker + age", ct,
...               family=sm.families.Binomial()).fit()
>>> p = dp.binned_residual_plot(glm)
Source code in src/depictr/diagnostics.py
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
def binned_residual_plot(model, n_bins=None, title=None, band="model"):
    """Binned residual plot for a binomial model (Gelman & Hill, 2007).

    Raw residuals from a logistic model are uninformative point by point, so the
    fitted probabilities are split into equal-count bins and the mean residual is
    plotted against the mean fitted value in each bin. The bins are the R
    twin's. Rows are ranked by their fitted probability with ties in row order,
    and the ranks are cut at multiples of n / n_bins. A row with zero weight
    takes no part in the fit and is left out.

    The grey band is plus or minus two standard errors of a bin's mean
    residual. By default (``band="model"``), they are the standard errors the
    fitted model implies. The model gives a row with fitted probability p_i
    over m_i trials, taken as a proportion, the variance
    scale * p_i * (1 - p_i) / m_i. Taking this as the variance of the row's
    residual, the band gives the mean of the n_b residuals in a bin the
    standard error sqrt(sum of their variances) / n_b. The fit absorbs a little
    of each row's variance, so a residual varies slightly less than this and
    the band errs on the wide side. Under a well-fitting model, about 95% of
    the points fall inside the band. A run of points outside it, or a clear
    trend in them, points to a missing predictor or the wrong functional form.

    The scale is 1 unless the GLM was fitted with an estimated one, the
    counterpart of the dispersion of R's quasibinomial family. statsmodels'
    Pearson estimate, ``fit(scale="X2")``, leaves out the trials of a
    two-column (successes, failures) response, so for such a fit the scale is
    recomputed as ``pearson_chi2 / df_resid``, which counts them, as R does.

    ``band="empirical"`` takes the standard error from the residuals in the bin,
    as their standard deviation over sqrt(n_b), the band of R's
    ``arm::binnedplot()``. It collapses in a bin whose outcomes are all alike,
    such as a bin without events, and so flags such bins even when the model is
    right.

    Parameters
    ----------
    model : statsmodels results object
        A fitted binomial GLM (logistic regression), or a Logit or Probit
        result, whose fitted probabilities are used.
    n_bins : int, optional
        Number of bins, at least 2. Defaults to the square root of the sample
        size, rounded down and kept between 2 and half the sample size, the
        rule of thumb in Gelman & Hill.
    title : str, optional
        Plot title.
    band : {"model", "empirical"}, default "model"
        Where the standard errors come from: the variance the fitted model
        implies for each residual, or the spread of the residuals in each bin.

    Returns
    -------
    plotnine.ggplot
        Its ``data`` hold one row per bin, with the columns ``fitted``,
        ``resid``, ``n``, ``se2`` (the band's half-width), ``outside`` (whether
        the point lies beyond the band), ``lower`` and ``upper``.

    References
    ----------
    Gelman, A., & Hill, J. (2007). Data analysis using regression and
    multilevel/hierarchical models. Cambridge University Press.

    Examples
    --------
    >>> import depictr as dp
    >>> import statsmodels.api as sm
    >>> import statsmodels.formula.api as smf
    >>> ct = dp.clinical_trial()
    >>> glm = smf.glm("adverse_event ~ biomarker + age", ct,
    ...               family=sm.families.Binomial()).fit()
    >>> p = dp.binned_residual_plot(glm)
    """
    _require_statsmodels()
    from scipy import stats

    if not _is_fitted_model(model):
        raise TypeError("binned_residual_plot needs a fitted statsmodels GLM.")
    # Only a binomial model is meaningful here: a binomial GLM, or a Logit or
    # Probit fit of the same kind of outcome.
    if not _is_binomial(model):
        raise ValueError(
            "binned_residual_plot expects a binomial GLM "
            "(family=sm.families.Binomial()) or a Logit or Probit fit."
        )
    if band not in ("model", "empirical"):
        raise ValueError("`band` must be 'model' or 'empirical'.")

    fitted = _fitted_values(model)
    # Residuals of the response as a proportion, as in the R twin. statsmodels
    # holds a two-column (successes, failures) response as proportions, but
    # its resid_response multiplies them back up to counts of successes.
    resid = np.asarray(model.model.endog, dtype=float) - fitted
    if _is_glm(model):
        # Trials per row, from a two-column response or from var_weights on a
        # proportion, as in _quantile_residuals().
        trials = (np.asarray(getattr(model.model, "n_trials", 1.0), dtype=float)
                  * np.asarray(model.model.var_weights, dtype=float))
        scale = _binomial_scale(model, fitted)
    else:
        trials = np.ones_like(fitted)
        scale = 1.0
    trials = np.broadcast_to(trials, fitted.shape)
    # A row with zero weight takes no part in the fit, and the model gives its
    # residual no finite variance.
    keep = np.isfinite(fitted) & np.isfinite(resid) & (trials > 0)
    fitted, resid, trials = fitted[keep], resid[keep], trials[keep]
    n = len(fitted)
    if n < 4:
        raise ValueError("Too few observations for a binned-residual plot.")
    if n_bins is None:
        # floor(sqrt(n)), kept between 2 and n // 2, as in the R twin.
        n_bins = max(2, min(math.isqrt(n), n // 2))
    elif (isinstance(n_bins, bool) or not isinstance(n_bins, numbers.Real)
          or not n_bins >= 2):
        raise ValueError("`n_bins` must be a single number >= 2.")
    else:
        n_bins = int(n_bins)

    # Equal-count bins as in the R twin: rank the fitted values, breaking ties
    # by row order as R's rank(ties.method = "first") does, and cut the ranks
    # at multiples of n / n_bins.
    rank = stats.rankdata(fitted, method="ordinal")
    bin_id = np.minimum(np.ceil(rank / (n / n_bins)), n_bins)
    # The variance of each residual under the model.
    resid_var = scale * fitted * (1 - fitted) / trials

    rows = []
    # The bins are numbered by rank, so they come in order of mean fitted value.
    for b in np.unique(bin_id):
        mask = bin_id == b
        nb = int(mask.sum())
        r = resid[mask]
        if band == "model":
            # The mean of independent residuals: the root of their summed
            # variances over their count.
            se = np.sqrt(resid_var[mask].sum()) / nb
        elif nb > 1:
            se = r.std(ddof=1) / np.sqrt(nb)
        else:
            se = np.nan
        rows.append({"fitted": fitted[mask].mean(), "resid": r.mean(), "n": nb,
                     "se2": 2 * se})
    df = pd.DataFrame(rows)
    df["outside"] = np.isfinite(df["se2"]) & (df["resid"].abs() > df["se2"])
    df["lower"] = -df["se2"]
    df["upper"] = df["se2"]

    return (
        ggplot(df, aes("fitted", "resid"))
        + geom_ribbon(aes(ymin="lower", ymax="upper"),
                      fill="#9e9e9e", alpha=0.25)
        + geom_line(aes(y="upper"), color="#9e9e9e", linetype="dashed")
        + geom_line(aes(y="lower"), color="#9e9e9e", linetype="dashed")
        + geom_hline(yintercept=0, color="#9e9e9e")
        + geom_point(color=BRAND, size=2.2)
        + labs(x="Mean predicted probability", y="Mean residual", title=title)
        + theme_depictr()
    )

residual_diagnostics_plot

residual_diagnostics_plot(model, title=None, glm_panels=True, seed=None)

Four-panel residual report for a fitted OLS, GLM, Logit or Probit model.

Composes the four checks R draws from plot.lm into a 2x2 grid: residuals against fitted values, the scale-location plot, a normal quantile-quantile plot of the residuals, and residuals against leverage. Read together they cover the usual assumptions of a linear fit: the right functional form, constant variance, approximately normal residuals, and no single point dominating the fit.

A GLM, Logit or Probit fit gets panels made for it (glm_panels=True), as in the R twin. The Q-Q panel plots randomised quantile residuals (Dunn & Smyth, 1996), which are standard normal under a correctly specified model whatever the family. The studentised residuals of a binary or count model are not expected to be normal, even when the model is right. The quantile residuals of a binomial or Poisson fit are randomised, so a departure worth acting on is one that persists when the panel is drawn again with another seed. A binomial model's residual panel is :func:depictr.binned_residual_plot (Gelman & Hill, 2007). Other families plot their deviance residuals against the fitted values. The R twin bins them too, but the binned plot here takes binomial models only. A family other than the binomial, Poisson, Gaussian and Gamma has no quantile residual here. Its Q-Q panel plots the deviance residuals, and the axis says so. A Logit or Probit fit is plotted against its fitted probabilities, as the binomial GLM of the same model is.

Parameters:

Name Type Description Default
model statsmodels results object

A fitted OLS, GLM, Logit or Probit result, such as smf.ols(...).fit().

required
title str

Accepted for API symmetry, but dropped with a warning: plotnine compositions cannot carry a figure-level title, so the grid keeps its per-panel titles instead.

None
glm_panels bool

For a GLM, Logit or Probit fit, whether to draw the quantile-residual and binned-residual panels. False draws the classic ones, which plot its deviance and studentised residuals. Ignored for any other model.

True
seed int

Seed for the randomisation of the quantile residuals of a binomial or Poisson fit, for a reproducible Q-Q panel. numpy's global random state is left alone.

None

Returns:

Type Description
Compose

A 2x2 composition with .draw and .save.

References

Dunn, P. K., & Smyth, G. K. (1996). Randomized quantile residuals. Journal of Computational and Graphical Statistics, 5(3), 236-244. https://doi.org/10.1080/10618600.1996.10474708

Fox, J., & Weisberg, S. (2019). An R companion to applied regression (3rd ed.). Sage.

Gelman, A., & Hill, J. (2007). Data analysis using regression and multilevel/hierarchical models. Cambridge University Press. https://doi.org/10.1017/CBO9780511790942

Examples:

>>> import depictr as dp
>>> import statsmodels.formula.api as smf
>>> cy = dp.crop_yield()
>>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
>>> p = dp.residual_diagnostics_plot(model)

A logistic GLM gets binned residuals and a Q-Q plot of quantile residuals, whose randomisation the seed fixes:

>>> import statsmodels.api as sm
>>> ct = dp.clinical_trial()
>>> glm = smf.glm("adverse_event ~ biomarker + age + arm", ct,
...               family=sm.families.Binomial()).fit()
>>> p = dp.residual_diagnostics_plot(glm, seed=1)
Source code in src/depictr/diagnostics_panels.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
def residual_diagnostics_plot(model, title=None, glm_panels=True, seed=None):
    """Four-panel residual report for a fitted OLS, GLM, Logit or Probit model.

    Composes the four checks R draws from ``plot.lm`` into a 2x2 grid: residuals
    against fitted values, the scale-location plot, a normal quantile-quantile
    plot of the residuals, and residuals against leverage. Read together they
    cover the usual assumptions of a linear fit: the right functional form,
    constant variance, approximately normal residuals, and no single point
    dominating the fit.

    A GLM, Logit or Probit fit gets panels made for it (``glm_panels=True``), as
    in the R twin. The Q-Q panel plots randomised quantile residuals (Dunn &
    Smyth, 1996), which are standard normal under a correctly specified model
    whatever the family. The studentised residuals of a binary or count model
    are not expected to be normal, even when the model is right. The quantile
    residuals of a binomial or Poisson fit are randomised, so a departure worth
    acting on is one that persists when the panel is drawn again with another
    ``seed``. A binomial model's residual panel is
    :func:`depictr.binned_residual_plot` (Gelman & Hill, 2007). Other families
    plot their deviance residuals against the fitted values. The R twin bins
    them too, but the binned plot here takes binomial models only. A family
    other than the binomial, Poisson, Gaussian and Gamma has no quantile
    residual here. Its Q-Q panel plots the deviance residuals, and the axis
    says so. A Logit or Probit fit is plotted against its fitted probabilities,
    as the binomial GLM of the same model is.

    Parameters
    ----------
    model : statsmodels results object
        A fitted OLS, GLM, Logit or Probit result, such as
        ``smf.ols(...).fit()``.
    title : str, optional
        Accepted for API symmetry, but dropped with a warning: plotnine
        compositions cannot carry a figure-level title, so the grid keeps its
        per-panel titles instead.
    glm_panels : bool, default True
        For a GLM, Logit or Probit fit, whether to draw the quantile-residual
        and binned-residual panels. ``False`` draws the classic ones, which plot
        its deviance and studentised residuals. Ignored for any other model.
    seed : int, optional
        Seed for the randomisation of the quantile residuals of a binomial or
        Poisson fit, for a reproducible Q-Q panel. numpy's global random state
        is left alone.

    Returns
    -------
    plotnine.composition.Compose
        A 2x2 composition with ``.draw`` and ``.save``.

    References
    ----------
    Dunn, P. K., & Smyth, G. K. (1996). Randomized quantile residuals.
    Journal of Computational and Graphical Statistics, 5(3), 236-244.
    https://doi.org/10.1080/10618600.1996.10474708

    Fox, J., & Weisberg, S. (2019). An R companion to applied regression
    (3rd ed.). Sage.

    Gelman, A., & Hill, J. (2007). Data analysis using regression and
    multilevel/hierarchical models. Cambridge University Press.
    https://doi.org/10.1017/CBO9780511790942

    Examples
    --------
    >>> import depictr as dp
    >>> import statsmodels.formula.api as smf
    >>> cy = dp.crop_yield()
    >>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
    >>> p = dp.residual_diagnostics_plot(model)

    A logistic GLM gets binned residuals and a Q-Q plot of quantile residuals,
    whose randomisation the seed fixes:

    >>> import statsmodels.api as sm
    >>> ct = dp.clinical_trial()
    >>> glm = smf.glm("adverse_event ~ biomarker + age + arm", ct,
    ...               family=sm.families.Binomial()).fit()
    >>> p = dp.residual_diagnostics_plot(glm, seed=1)
    """
    _check_model(model, "residual_diagnostics_plot")
    resid_panel, qq = _residual_and_qq_panels(model, glm_panels, seed)
    return arrange_plots(
        resid_panel,
        _scale_location(model, title="Scale-location"),
        qq,
        _residuals_vs_leverage(model, title="Residuals vs leverage"),
        ncol=2,
        title=title,
        # Untagged, as in the R twin, which composes these panels with
        # patchwork directly.
        tag_levels=None,
    )

model_report

model_report(model, title=None, glm_panels=True, seed=None)

One-figure dashboard pairing the coefficient plot with key diagnostics.

Puts the three plots a reader usually wants first side by side: the coefficient (forest) plot of the estimates and their confidence intervals, a residual panel, and the normal quantile-quantile plot. It is a quick "is the model worth trusting, and what does it say" view, with the full set of checks left to :func:residual_diagnostics_plot. A GLM, Logit or Probit fit gets the panels that function draws for it: a Q-Q plot of randomised quantile residuals and, for a binomial model, binned residuals in place of the scatter.

Parameters:

Name Type Description Default
model statsmodels results object

A fitted OLS, GLM, Logit or Probit result exposing params and conf_int (for the coefficient plot) as well as fitted values and residuals.

required
title str

Accepted for API symmetry, but dropped with a warning: plotnine compositions cannot carry a figure-level title, so the dashboard keeps its per-panel titles instead.

None
glm_panels bool

For a GLM, Logit or Probit fit, whether to draw the quantile-residual and binned-residual panels. False draws the classic ones. Ignored for any other model.

True
seed int

Seed for the randomisation of the quantile residuals of a binomial or Poisson fit, for a reproducible Q-Q panel. numpy's global random state is left alone.

None

Returns:

Type Description
Compose

A composition with .draw and .save, its panels tagged A, B and C, as the R twin tags its own.

Examples:

>>> import depictr as dp
>>> import statsmodels.formula.api as smf
>>> cy = dp.crop_yield()
>>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
>>> p = dp.model_report(model)
>>> ct = dp.clinical_trial()
>>> logit = smf.logit("adverse_event ~ biomarker + age + arm", ct).fit(disp=0)
>>> p = dp.model_report(logit, seed=1)
Source code in src/depictr/diagnostics_panels.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
def model_report(model, title=None, glm_panels=True, seed=None):
    """One-figure dashboard pairing the coefficient plot with key diagnostics.

    Puts the three plots a reader usually wants first side by side: the
    coefficient (forest) plot of the estimates and their confidence intervals,
    a residual panel, and the normal quantile-quantile plot. It is a quick "is
    the model worth trusting, and what does it say" view, with the full set of
    checks left to :func:`residual_diagnostics_plot`. A GLM, Logit or Probit
    fit gets the panels that function draws for it: a Q-Q plot of randomised
    quantile residuals and, for a binomial model, binned residuals in place of
    the scatter.

    Parameters
    ----------
    model : statsmodels results object
        A fitted OLS, GLM, Logit or Probit result exposing ``params`` and
        ``conf_int`` (for the coefficient plot) as well as fitted values and
        residuals.
    title : str, optional
        Accepted for API symmetry, but dropped with a warning: plotnine
        compositions cannot carry a figure-level title, so the dashboard keeps
        its per-panel titles instead.
    glm_panels : bool, default True
        For a GLM, Logit or Probit fit, whether to draw the quantile-residual
        and binned-residual panels. ``False`` draws the classic ones. Ignored
        for any other model.
    seed : int, optional
        Seed for the randomisation of the quantile residuals of a binomial or
        Poisson fit, for a reproducible Q-Q panel. numpy's global random state
        is left alone.

    Returns
    -------
    plotnine.composition.Compose
        A composition with ``.draw`` and ``.save``, its panels tagged A, B and
        C, as the R twin tags its own.

    Examples
    --------
    >>> import depictr as dp
    >>> import statsmodels.formula.api as smf
    >>> cy = dp.crop_yield()
    >>> model = smf.ols('Q("yield") ~ fertiliser + rainfall + soil_ph', cy).fit()
    >>> p = dp.model_report(model)
    >>> ct = dp.clinical_trial()
    >>> logit = smf.logit("adverse_event ~ biomarker + age + arm", ct).fit(disp=0)
    >>> p = dp.model_report(logit, seed=1)
    """
    _check_model(model, "model_report")
    resid_panel, qq = _residual_and_qq_panels(model, glm_panels, seed)
    return arrange_plots(
        coefficient_plot(model, title="Coefficients"),
        resid_panel,
        qq,
        ncol=3,
        title=title,
        tag_levels="A",
    )

Classification

The standard classification curves and tables, most of them computed by scikit-learn and all redrawn under the shared theme. The gains, lift and calibration curves are computed as the R twin computes them.

These plots are shown, with the code that produced them, on the classification and survival page of the gallery.

roc_curve_plot

roc_curve_plot(y_true, y_score, title=None)

ROC curve with the area under the curve (AUC) annotated.

Parameters:

Name Type Description Default
y_true array - like

Binary outcomes, coded 0/1, as booleans or as a categorical with two categories, whose last category is the positive class, as in the R twin. Both classes must be present: with only one, the true or false positive rate has no denominator and the AUC is undefined.

required
y_score array - like

Predicted scores or probabilities for the positive class.

required
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> ct = dp.clinical_trial()
>>> score = 1 / (1 + np.exp(-ct["biomarker"]))
>>> p = dp.roc_curve_plot(ct["adverse_event"], score)
Source code in src/depictr/classification.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
def roc_curve_plot(y_true, y_score, title=None):
    """ROC curve with the area under the curve (AUC) annotated.

    Parameters
    ----------
    y_true : array-like
        Binary outcomes, coded 0/1, as booleans or as a categorical with two
        categories, whose last category is the positive class, as in the R
        twin. Both classes must be present: with only one, the true or false
        positive rate has no denominator and the AUC is undefined.
    y_score : array-like
        Predicted scores or probabilities for the positive class.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> ct = dp.clinical_trial()
    >>> score = 1 / (1 + np.exp(-ct["biomarker"]))
    >>> p = dp.roc_curve_plot(ct["adverse_event"], score)
    """
    m = _require_sklearn()
    y_true = _binary_outcome(y_true)
    _positive_count(y_true, "ROC needs both positive and negative outcomes.")
    fpr, tpr, _ = m.roc_curve(y_true, y_score)
    auc = m.auc(fpr, tpr)
    df = pd.DataFrame({"fpr": fpr, "tpr": tpr})
    return (
        ggplot(df, aes("fpr", "tpr"))
        + geom_abline(intercept=0, slope=1, linetype="dashed", color="#9e9e9e")
        + geom_line(color=BRAND, size=0.9)
        + annotate("text", x=0.98, y=0.04, ha="right",
                   label=f"AUC = {auc:.3f}", color=BRAND, fontweight="bold")
        + labs(x="False positive rate", y="True positive rate", title=title)
        + theme_depictr()
    )

pr_curve_plot

pr_curve_plot(y_true, y_score, title=None)

Precision-recall curve with the average precision (AP) annotated.

The dashed horizontal line is the positive rate, the precision a random classifier achieves, which is the baseline a precision-recall curve is read against (unlike a ROC curve, whose baseline is fixed at the diagonal).

Parameters:

Name Type Description Default
y_true array - like

Binary outcomes, coded 0/1, as booleans or as a categorical with two categories, whose last category is the positive class, as in the R twin. Both classes must be present: with only one, precision or recall has no denominator and the average precision is undefined.

required
y_score array - like

Predicted scores or probabilities for the positive class.

required
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> ct = dp.clinical_trial()
>>> score = 1 / (1 + np.exp(-ct["biomarker"]))
>>> p = dp.pr_curve_plot(ct["adverse_event"], score)
Source code in src/depictr/classification.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def pr_curve_plot(y_true, y_score, title=None):
    """Precision-recall curve with the average precision (AP) annotated.

    The dashed horizontal line is the positive rate, the precision a random
    classifier achieves, which is the baseline a precision-recall curve is read
    against (unlike a ROC curve, whose baseline is fixed at the diagonal).

    Parameters
    ----------
    y_true : array-like
        Binary outcomes, coded 0/1, as booleans or as a categorical with two
        categories, whose last category is the positive class, as in the R
        twin. Both classes must be present: with only one, precision or recall
        has no denominator and the average precision is undefined.
    y_score : array-like
        Predicted scores or probabilities for the positive class.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> ct = dp.clinical_trial()
    >>> score = 1 / (1 + np.exp(-ct["biomarker"]))
    >>> p = dp.pr_curve_plot(ct["adverse_event"], score)
    """
    m = _require_sklearn()
    y_true = _binary_outcome(y_true)
    _positive_count(y_true,
                    "Precision-recall needs both positive and negative outcomes.")
    precision, recall, _ = m.precision_recall_curve(y_true, y_score)
    ap = m.average_precision_score(y_true, y_score)
    baseline = float(np.mean(np.asarray(y_true)))
    df = pd.DataFrame({"recall": recall, "precision": precision})
    return (
        ggplot(df, aes("recall", "precision"))
        + geom_abline(intercept=baseline, slope=0, linetype="dashed", color="#9e9e9e")
        + geom_line(color=BRAND, size=0.9)
        + annotate("text", x=0.98, y=0.04, ha="right",
                   label=f"AP = {ap:.3f}", color=BRAND, fontweight="bold")
        + labs(x="Recall", y="Precision", title=title)
        + theme_depictr()
    )

confusion_matrix_plot

confusion_matrix_plot(y_true, y_pred, normalise=None, title=None)

Confusion-matrix heatmap.

Each count is drawn in black or white, whichever has the higher contrast against its cell.

Parameters:

Name Type Description Default
y_true array - like

True and predicted labels.

required
y_pred array - like

True and predicted labels.

required
normalise (None, 'true', 'pred', 'all')

Passed to scikit-learn's confusion_matrix(normalize=...).

None
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> ct = dp.clinical_trial()
>>> score = 1 / (1 + np.exp(-ct["biomarker"]))
>>> p = dp.confusion_matrix_plot(ct["adverse_event"], (score > 0.6).astype(int))
Source code in src/depictr/classification.py
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
def confusion_matrix_plot(y_true, y_pred, normalise=None, title=None):
    """Confusion-matrix heatmap.

    Each count is drawn in black or white, whichever has the higher contrast
    against its cell.

    Parameters
    ----------
    y_true, y_pred : array-like
        True and predicted labels.
    normalise : {None, "true", "pred", "all"}
        Passed to scikit-learn's ``confusion_matrix(normalize=...)``.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> ct = dp.clinical_trial()
    >>> score = 1 / (1 + np.exp(-ct["biomarker"]))
    >>> p = dp.confusion_matrix_plot(ct["adverse_event"], (score > 0.6).astype(int))
    """
    m = _require_sklearn()
    labels = sorted(pd.unique(pd.concat([pd.Series(y_true), pd.Series(y_pred)])))
    cm = m.confusion_matrix(y_true, y_pred, labels=labels, normalize=normalise)
    long = (pd.DataFrame(cm, index=labels, columns=labels)
            .reset_index(names="true")
            .melt(id_vars="true", var_name="predicted", value_name="count"))
    fmt = "{:.2f}" if normalise else "{:.0f}"
    long["label"] = long["count"].map(lambda v: fmt.format(v))
    long["true"] = pd.Categorical(long["true"], categories=labels[::-1], ordered=True)
    long["predicted"] = pd.Categorical(long["predicted"], categories=labels, ordered=True)
    fill_scale = scale_fill_gradientn(
        colors=depictr_palette(7, kind="sequential"),
        name=("Proportion" if normalise else "Count"),
    )
    # Each count is black or white, whichever contrasts more with its tile as
    # the fill scale paints it. The busiest cell is the darkest, where the
    # near-black every count used to be drawn in sat at 1.3:1.
    long["label_colour"] = _legible_text_colour(
        _scale_fills(fill_scale, long["count"]))
    return (
        ggplot(long, aes("predicted", "true", fill="count"))
        + geom_tile(color="white")
        + geom_text(aes(label="label", color="label_colour"), size=10)
        + scale_color_identity()
        + fill_scale
        + labs(x="Predicted", y="Actual", title=title)
        + theme_depictr(grid="none")
    )

calibration_plot

calibration_plot(y_true, y_score, n_bins=10, title=None, conf_level=0.95, strategy='quantile', bins=None)

Reliability (calibration) curve of predicted against observed frequencies.

The cases are grouped into bins by their predicted probability, and each bin is drawn at its mean predicted probability and its observed event rate, with the dashed diagonal marking perfect calibration. By default the bins hold equal numbers of cases, as in the R twin, so each point rests on about the same evidence. Each point is sized by its number of cases and carries a Wilson interval for its observed rate (Wilson, 1927), so a bin backed by few cases is not over-read. Brown et al. (2001) recommend the Wilson interval for small samples. Its coverage stays close to the nominal level where the Wald interval's is erratic, though it dips for rates very close to 0 or 1.

Parameters:

Name Type Description Default
y_true array - like

Binary outcomes, coded 0/1, as booleans or as a categorical with two categories, whose last category is the positive class, as in the R twin. Any other value, a missing one included, raises ValueError.

required
y_score array - like

Predicted probabilities from a fitted model, on the probability scale. Unlike the ROC and gains charts, which only rank cases, a reliability curve compares the predicted probability with the observed frequency, so an arbitrary monotone score would misstate the calibration. A value outside [0, 1], or a missing one, raises ValueError.

required
n_bins int

Number of bins requested. Fewer points are drawn when bins merge or come out empty (see strategy).

10
title str
None
conf_level float or None

Confidence level of the Wilson intervals, strictly between 0 and 1. None leaves the intervals out.

0.95
strategy ('quantile', 'uniform')

How the bins are cut. "quantile" cuts at the quantiles of the scores, computed as R's quantile() computes them by default, into bins of nearly equal count. Each bin includes its upper edge and the lowest its lower edge too, as R's cut(include.lowest = TRUE) assigns them. Tied scores can make edges coincide, and repeated edges are merged, so fewer than n_bins bins may remain. Fewer than two raise ValueError. "uniform" cuts [0, 1] into n_bins bins of equal width, the bins of depictr 0.3.0, and drops the empty ones. A rare outcome whose scores never approach 1 then leaves fewer points than bins, the highest of them often resting on a handful of cases.

"quantile"
bins int

The R twin's name for n_bins, which it replaces when given.

None

Returns:

Type Description
ggplot

Its data hold one row per bin, with the columns predicted, observed, successes and n and, unless conf_level is None, conf_low and conf_high.

References

Brown, L. D., Cai, T. T., & DasGupta, A. (2001). Interval estimation for a binomial proportion. Statistical Science, 16(2), 101-133. https://doi.org/10.1214/ss/1009213286

Wilson, E. B. (1927). Probable inference, the law of succession, and statistical inference. Journal of the American Statistical Association, 22(158), 209-212. https://doi.org/10.1080/01621459.1927.10502953

Examples:

>>> import depictr as dp
>>> from sklearn.linear_model import LogisticRegression
>>> ct = dp.clinical_trial()
>>> X = ct[["biomarker", "age"]]
>>> fit = LogisticRegression().fit(X, ct["adverse_event"])
>>> p = dp.calibration_plot(ct["adverse_event"],
...                         fit.predict_proba(X)[:, 1], n_bins=5)
Source code in src/depictr/classification.py
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
def calibration_plot(y_true, y_score, n_bins=10, title=None, conf_level=0.95,
                     strategy="quantile", bins=None):
    """Reliability (calibration) curve of predicted against observed frequencies.

    The cases are grouped into bins by their predicted probability, and each
    bin is drawn at its mean predicted probability and its observed event rate,
    with the dashed diagonal marking perfect calibration. By default the bins
    hold equal numbers of cases, as in the R twin, so each point rests on about
    the same evidence. Each point is sized by its number of cases and carries a
    Wilson interval for its observed rate (Wilson, 1927), so a bin backed by
    few cases is not over-read. Brown et al. (2001) recommend the Wilson
    interval for small samples. Its coverage stays close to the nominal level
    where the Wald interval's is erratic, though it dips for rates very close
    to 0 or 1.

    Parameters
    ----------
    y_true : array-like
        Binary outcomes, coded 0/1, as booleans or as a categorical with two
        categories, whose last category is the positive class, as in the R
        twin. Any other value, a missing one included, raises ``ValueError``.
    y_score : array-like
        Predicted probabilities from a fitted model, on the probability scale.
        Unlike the ROC and gains charts, which only rank cases, a reliability
        curve compares the predicted probability with the observed frequency,
        so an arbitrary monotone score would misstate the calibration. A value
        outside [0, 1], or a missing one, raises ``ValueError``.
    n_bins : int
        Number of bins requested. Fewer points are drawn when bins merge or
        come out empty (see ``strategy``).
    title : str, optional
    conf_level : float or None
        Confidence level of the Wilson intervals, strictly between 0 and 1.
        ``None`` leaves the intervals out.
    strategy : {"quantile", "uniform"}
        How the bins are cut. ``"quantile"`` cuts at the quantiles of the
        scores, computed as R's ``quantile()`` computes them by default, into
        bins of nearly equal count. Each bin includes its upper edge and the
        lowest its lower edge too, as R's ``cut(include.lowest = TRUE)``
        assigns them. Tied scores can make edges coincide, and repeated edges
        are merged, so fewer than ``n_bins`` bins may remain. Fewer than two
        raise ``ValueError``. ``"uniform"`` cuts [0, 1] into ``n_bins`` bins
        of equal width, the bins of depictr 0.3.0, and drops the empty ones.
        A rare outcome whose scores never approach 1 then leaves fewer points
        than bins, the highest of them often resting on a handful of cases.
    bins : int, optional
        The R twin's name for ``n_bins``, which it replaces when given.

    Returns
    -------
    plotnine.ggplot
        Its data hold one row per bin, with the columns ``predicted``,
        ``observed``, ``successes`` and ``n`` and, unless ``conf_level`` is
        ``None``, ``conf_low`` and ``conf_high``.

    References
    ----------
    Brown, L. D., Cai, T. T., & DasGupta, A. (2001). Interval estimation for a
    binomial proportion. Statistical Science, 16(2), 101-133.
    https://doi.org/10.1214/ss/1009213286

    Wilson, E. B. (1927). Probable inference, the law of succession, and
    statistical inference. Journal of the American Statistical Association,
    22(158), 209-212. https://doi.org/10.1080/01621459.1927.10502953

    Examples
    --------
    >>> import depictr as dp
    >>> from sklearn.linear_model import LogisticRegression
    >>> ct = dp.clinical_trial()
    >>> X = ct[["biomarker", "age"]]
    >>> fit = LogisticRegression().fit(X, ct["adverse_event"])
    >>> p = dp.calibration_plot(ct["adverse_event"],
    ...                         fit.predict_proba(X)[:, 1], n_bins=5)
    """
    if bins is not None:
        if n_bins not in (10, bins):
            raise TypeError(
                "Give the number of bins as `n_bins` or as `bins`, not both.")
        n_bins = bins
    if (isinstance(n_bins, bool) or not isinstance(n_bins, numbers.Integral)
            or n_bins < 1):
        raise ValueError("`n_bins` must be a positive whole number.")
    if strategy not in ("quantile", "uniform"):
        raise ValueError('`strategy` must be "quantile" or "uniform".')
    _check_conf_level(conf_level, allow_none=True)

    y_true, y_score = _outcome_and_score(_binary_outcome(y_true), y_score)
    try:
        y = y_true.astype(float)
    except (TypeError, ValueError):
        y = None
    if y is None or not np.all((y == 0) | (y == 1)):
        raise ValueError("`y_true` must hold outcomes coded 0/1.")
    # A missing score fails this test too, since it is no probability at all.
    if not np.all((y_score >= 0) & (y_score <= 1)):
        raise ValueError("`y_score` must hold probabilities between 0 and 1.")
    if len(y_score) == 0:
        raise ValueError("Too few distinct scores to form bins.")

    df = _calibration_bins(y, y_score, n_bins, strategy)
    if conf_level is not None:
        df["conf_low"], df["conf_high"] = _wilson_interval(
            df["successes"], df["n"], conf_level)
    p = (
        ggplot(df, aes("predicted", "observed"))
        + geom_abline(intercept=0, slope=1, linetype="dashed", color="#9e9e9e")
    )
    if conf_level is not None:
        # The R twin draws its caps 0.02 wide on a unit-square axis, a fiftieth
        # of its width. This axis spans the bins alone, so the caps take a
        # fiftieth of that span, which keeps crowded bins from overlapping.
        span = float(np.ptp(df["predicted"].to_numpy()))
        p = p + geom_errorbar(aes(ymin="conf_low", ymax="conf_high"),
                              width=0.02 * span if span > 0 else 0.02,
                              size=0.5, color=BRAND, alpha=0.7)
    return (
        p
        + geom_line(color=BRAND, size=0.7)
        + geom_point(aes(size="n"), color=BRAND, alpha=0.7)
        + scale_size_area(name="n", max_size=7)
        + labs(x="Mean predicted probability", y="Observed frequency", title=title)
        + theme_depictr()
    )

gain_plot

gain_plot(y_true, y_score, title=None)

Cumulative gains chart: positives captured as more of the ranked population is targeted.

The population is sorted by y_score, best first, and the curve traces the share of all positive cases captured by each depth. The dashed diagonal is random targeting.

Cases with tied scores are targeted together, as in the R twin. The curve takes a point only once every case of a run of equal scores is in, and it runs straight to that point, which is the share expected when tied cases are reached in random order (Fawcett, 2006). The curve therefore does not depend on the order of the rows. With every score distinct, each case is a point of its own.

Parameters:

Name Type Description Default
y_true array - like

Binary outcomes, coded 0/1, as booleans or as a categorical with two categories, whose last category is the positive class, as in the R twin. Both classes must be present: with no positives the share captured has no denominator, and with no negatives every depth captures everything. A missing outcome raises ValueError.

required
y_score array - like

Predicted scores or probabilities for the positive class, one per outcome. Only the ranking matters, so any monotone score works. A missing score, which has no rank, raises ValueError.

required
title str
None

Returns:

Type Description
ggplot
References

Fawcett, T. (2006). An introduction to ROC analysis. Pattern Recognition Letters, 27(8), 861-874. https://doi.org/10.1016/j.patrec.2005.10.010

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> ct = dp.clinical_trial()
>>> score = 1 / (1 + np.exp(-ct["biomarker"]))
>>> p = dp.gain_plot(ct["adverse_event"], score)
Source code in src/depictr/classification.py
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
def gain_plot(y_true, y_score, title=None):
    """Cumulative gains chart: positives captured as more of the ranked population is targeted.

    The population is sorted by ``y_score``, best first, and the curve traces
    the share of all positive cases captured by each depth. The dashed diagonal
    is random targeting.

    Cases with tied scores are targeted together, as in the R twin. The curve
    takes a point only once every case of a run of equal scores is in, and it
    runs straight to that point, which is the share expected when tied cases
    are reached in random order (Fawcett, 2006). The curve therefore does not
    depend on the order of the rows. With every score distinct, each case is a
    point of its own.

    Parameters
    ----------
    y_true : array-like
        Binary outcomes, coded 0/1, as booleans or as a categorical with two
        categories, whose last category is the positive class, as in the R
        twin. Both classes must be present: with no positives the share
        captured has no denominator, and with no negatives every depth captures
        everything. A missing outcome raises ``ValueError``.
    y_score : array-like
        Predicted scores or probabilities for the positive class, one per
        outcome. Only the ranking matters, so any monotone score works. A
        missing score, which has no rank, raises ``ValueError``.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    References
    ----------
    Fawcett, T. (2006). An introduction to ROC analysis. Pattern Recognition
    Letters, 27(8), 861-874. https://doi.org/10.1016/j.patrec.2005.10.010

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> ct = dp.clinical_trial()
    >>> score = 1 / (1 + np.exp(-ct["biomarker"]))
    >>> p = dp.gain_plot(ct["adverse_event"], score)
    """
    population, captured = _gain_table(y_true, y_score)
    df = pd.DataFrame({"population": population, "captured": captured})
    return (
        ggplot(df, aes("population", "captured"))
        + geom_abline(intercept=0, slope=1, linetype="dashed", color="#9e9e9e")
        + geom_line(color=BRAND, size=0.9)
        + labs(x="Population targeted", y="Positive cases captured", title=title)
        + theme_depictr()
    )

lift_plot

lift_plot(y_true, y_score, title=None)

Cumulative lift chart.

Lift is the share of positives captured at a given depth of the score-ordered population, divided by that depth. A lift of 3 in the top 10% means that decile holds three times the baseline positive rate. The dashed line at 1 is random targeting.

Cases with tied scores are targeted together, as in :func:depictr.gain_plot and the R twin, so the curve has a point at the end of each run of equal scores and does not depend on the order of the rows. It starts at the first such point, since lift has no value at a depth of zero.

Parameters:

Name Type Description Default
y_true array - like

Binary outcomes, coded 0/1, as booleans or as a categorical with two categories, whose last category is the positive class, as in the R twin. Both classes must be present: lift is a ratio to the baseline positive rate, which is zero with no positives and one at every depth with no negatives. A missing outcome raises ValueError.

required
y_score array - like

Predicted scores or probabilities for the positive class, one per outcome. Only the ranking matters, so any monotone score works. A missing score, which has no rank, raises ValueError.

required
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> ct = dp.clinical_trial()
>>> score = 1 / (1 + np.exp(-ct["biomarker"]))
>>> p = dp.lift_plot(ct["adverse_event"], score)
Source code in src/depictr/classification.py
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
def lift_plot(y_true, y_score, title=None):
    """Cumulative lift chart.

    Lift is the share of positives captured at a given depth of the
    score-ordered population, divided by that depth. A lift of 3 in the top 10%
    means that decile holds three times the baseline positive rate. The dashed
    line at 1 is random targeting.

    Cases with tied scores are targeted together, as in
    :func:`depictr.gain_plot` and the R twin, so the curve has a point at the
    end of each run of equal scores and does not depend on the order of the
    rows. It starts at the first such point, since lift has no value at a depth
    of zero.

    Parameters
    ----------
    y_true : array-like
        Binary outcomes, coded 0/1, as booleans or as a categorical with two
        categories, whose last category is the positive class, as in the R
        twin. Both classes must be present: lift is a ratio to the baseline
        positive rate, which is zero with no positives and one at every depth
        with no negatives. A missing outcome raises ``ValueError``.
    y_score : array-like
        Predicted scores or probabilities for the positive class, one per
        outcome. Only the ranking matters, so any monotone score works. A
        missing score, which has no rank, raises ``ValueError``.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> ct = dp.clinical_trial()
    >>> score = 1 / (1 + np.exp(-ct["biomarker"]))
    >>> p = dp.lift_plot(ct["adverse_event"], score)
    """
    population, captured = _gain_table(y_true, y_score)
    # Drop the origin, where the ratio would be 0 / 0.
    df = pd.DataFrame({"population": population[1:],
                       "lift": captured[1:] / population[1:]})
    return (
        ggplot(df, aes("population", "lift"))
        + geom_hline(yintercept=1, linetype="dashed", color="#9e9e9e")
        + geom_line(color=BRAND, size=0.9)
        + labs(x="Population targeted", y="Cumulative lift", title=title)
        + theme_depictr()
    )

threshold_plot

threshold_plot(y_true, y_score, title=None)

Sensitivity, specificity, precision and F1 across the decision threshold.

Sweeps the probability cut-off and plots each metric, so the trade-off when choosing an operating point can be read straight off the curves.

A case is predicted positive when its score is at or above the threshold. The metrics are evaluated at every distinct score, the points at which the confusion matrix can change, so the curves are exact step functions. Between two adjacent scores, each metric keeps its value at the upper one, so each step changes at a score and runs flat below it. Where no positive case is predicted positive, precision and recall are 0 and F1 is taken as 0. The R twin computes and draws the same values.

Parameters:

Name Type Description Default
y_true array - like

Binary outcomes, coded 0/1, as booleans or as a categorical with two categories, whose last category is the positive class, as in the R twin. Both classes must be present: with only one, sensitivity or specificity has no denominator at any threshold. A missing outcome raises ValueError.

required
y_score array - like

Predicted scores or probabilities for the positive class, one per outcome. A missing score raises ValueError.

required
title str
None

Returns:

Type Description
ggplot

Examples:

>>> import depictr as dp
>>> import numpy as np
>>> ct = dp.clinical_trial()
>>> score = 1 / (1 + np.exp(-ct["biomarker"]))
>>> p = dp.threshold_plot(ct["adverse_event"], score)
Source code in src/depictr/classification.py
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
def threshold_plot(y_true, y_score, title=None):
    """Sensitivity, specificity, precision and F1 across the decision threshold.

    Sweeps the probability cut-off and plots each metric, so the trade-off when
    choosing an operating point can be read straight off the curves.

    A case is predicted positive when its score is at or above the threshold.
    The metrics are evaluated at every distinct score, the points at which the
    confusion matrix can change, so the curves are exact step functions. Between
    two adjacent scores, each metric keeps its value at the upper one, so each
    step changes at a score and runs flat below it. Where no positive case is
    predicted positive, precision and recall are 0 and F1 is taken as 0. The R
    twin computes and draws the same values.

    Parameters
    ----------
    y_true : array-like
        Binary outcomes, coded 0/1, as booleans or as a categorical with two
        categories, whose last category is the positive class, as in the R
        twin. Both classes must be present: with only one, sensitivity or
        specificity has no denominator at any threshold. A missing outcome
        raises ``ValueError``.
    y_score : array-like
        Predicted scores or probabilities for the positive class, one per
        outcome. A missing score raises ``ValueError``.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    Examples
    --------
    >>> import depictr as dp
    >>> import numpy as np
    >>> ct = dp.clinical_trial()
    >>> score = 1 / (1 + np.exp(-ct["biomarker"]))
    >>> p = dp.threshold_plot(ct["adverse_event"], score)
    """
    _require_sklearn()
    y_true, y_score = _outcome_and_score(_binary_outcome(y_true), y_score)
    # A missing score would rank as a run of its own and a missing outcome
    # would count as neither class, so the cumulative counts need both.
    for name, values in (("y_true", y_true), ("y_score", y_score)):
        if pd.isna(values).any():
            raise ValueError(
                f"`{name}` holds a missing value. A threshold sweep needs an "
                "outcome and a score for every case, so drop incomplete cases "
                "first."
            )
    _positive_count(y_true,
                    "A threshold sweep needs both positive and negative outcomes.")
    # Every distinct score, as in the R twin. Past 200 distinct scores the
    # sweep used to move to 200 quantiles of the scores, which fall between
    # the points where the metrics change.
    thresholds, tp, fp = _threshold_counts(y_true, y_score)
    n_pos = int((y_true == 1).sum())
    n_neg = int((y_true == 0).sum())
    sens = tp / n_pos
    spec = (n_neg - fp) / n_neg
    # With outcomes coded 0/1, the highest score predicts at least one
    # positive, so precision is defined at every threshold.
    predicted = tp + fp
    prec = np.divide(tp, predicted, out=np.full(len(tp), np.nan),
                     where=predicted > 0)
    total = prec + sens
    f1 = np.divide(2 * prec * sens, total, out=np.zeros_like(total),
                   where=total > 0)
    wide = pd.DataFrame({"threshold": thresholds[::-1], "Sensitivity": sens[::-1],
                         "Specificity": spec[::-1], "Precision": prec[::-1],
                         "F1": f1[::-1]})
    long = wide.melt(id_vars="threshold", var_name="metric", value_name="value")
    # A line would join adjacent thresholds with slopes through values that no
    # threshold gives. Every threshold between two adjacent scores predicts what
    # the upper score predicts. geom_step() orders the points by threshold, and
    # direction "vh" changes at each score and runs flat below it.
    return (
        ggplot(long, aes("threshold", "value", color="metric"))
        + geom_step(direction="vh", size=0.8, na_rm=True)
        + scale_colour_depictr()
        + labs(x="Decision threshold", y="Metric value", color="", title=title)
        + theme_depictr()
    )

Theme, palette and accessibility

The shared theme and the colourblind-safe palette, the tools that verify the palette stays distinguishable under colour-vision deficiency, the audit that checks a finished figure rather than the palette it was built from, and the helper that tidies model term names into labels.

The palette, the colour-vision checks and the figure audit are shown in use on the accessibility page of the gallery.

theme_depictr

theme_depictr(base_size=11, grid='xy', base_family=None)

Return the depictr plotnine theme.

Parameters:

Name Type Description Default
base_size float

Base font size in points.

11
grid ('xy', 'x', 'y', 'none')

Which major gridlines to keep.

"xy"
base_family str

Base font family, such as "Arial" for a journal that asks for it. None, the default, takes plotnine's plotnine.options.base_family, which is "sans-serif" unless it has been set. Setting that option once, as in plotnine.options.base_family = "Arial", sets the font of every depictr plot drawn afterwards, as depictr_options(base_family =) does in the R twin. A family matplotlib cannot find is replaced by its default, DejaVu Sans, with a message from matplotlib's font_manager logger when the plot is drawn.

None

Returns:

Type Description
theme

A theme object to add to any plot with +.

Examples:

>>> import depictr as dp
>>> ld = dp.lexical_decision()
>>> p = dp.ecdf_plot(ld, "RT") + dp.theme_depictr(base_size=13, grid="y")
>>> p = dp.ecdf_plot(ld, "RT") + dp.theme_depictr(base_family="serif")
Source code in src/depictr/theme.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def theme_depictr(base_size: float = 11, grid: str = "xy",
                  base_family: str | None = None) -> theme:
    """Return the depictr plotnine theme.

    Parameters
    ----------
    base_size : float
        Base font size in points.
    grid : {"xy", "x", "y", "none"}
        Which major gridlines to keep.
    base_family : str, optional
        Base font family, such as ``"Arial"`` for a journal that asks for it.
        ``None``, the default, takes plotnine's ``plotnine.options.base_family``,
        which is ``"sans-serif"`` unless it has been set. Setting that option
        once, as in ``plotnine.options.base_family = "Arial"``, sets the font of
        every depictr plot drawn afterwards, as ``depictr_options(base_family
        =)`` does in the R twin. A family matplotlib cannot find is replaced by
        its default, DejaVu Sans, with a message from matplotlib's
        ``font_manager`` logger when the plot is drawn.

    Returns
    -------
    plotnine.theme
        A theme object to add to any plot with ``+``.

    Examples
    --------
    >>> import depictr as dp
    >>> ld = dp.lexical_decision()
    >>> p = dp.ecdf_plot(ld, "RT") + dp.theme_depictr(base_size=13, grid="y")
    >>> p = dp.ecdf_plot(ld, "RT") + dp.theme_depictr(base_family="serif")
    """
    if grid not in {"xy", "x", "y", "none"}:
        raise ValueError("`grid` must be 'xy', 'x', 'y' or 'none'.")
    if base_family is not None and (not isinstance(base_family, str)
                                    or not base_family.strip()):
        raise ValueError("`base_family` must be a font family name, or None.")

    th = theme_minimal(base_size=base_size, base_family=base_family) + theme(
        plot_title=element_text(
            color=depictr_brand(), weight="bold", ha="center",
            size=base_size * 1.15, margin={"b": base_size * 0.5},
        ),
        plot_subtitle=element_text(color="#4d4d4d", ha="center",
                                   margin={"b": base_size * 0.4}),
        panel_grid_minor=element_blank(),
        panel_grid_major=element_line(color="#e6e6e6", size=0.4),
        # Bold, centred legend titles, matching the depictr R defaults. The
        # bottom margin lifts the title clear of the first key so it reads as a
        # heading rather than crowding the labels.
        legend_title=element_text(weight="bold", ha="center", margin={"b": 6}),
        # A white key with a thin border so a light-coloured swatch (e.g. a pale
        # grey "Present" tile) stays delineated wherever a legend appears.
        legend_key=element_rect(fill="#ffffff", color="#cccccc", size=0.3),
        legend_key_spacing_y=4,
        strip_background=element_rect(fill="#f5f5f5", color="none"),
        strip_text=element_text(size=base_size),
    )

    if grid in {"x", "none"}:
        th += theme(panel_grid_major_y=element_blank())
    if grid in {"y", "none"}:
        th += theme(panel_grid_major_x=element_blank())
    return th

scale_colour_depictr

scale_colour_depictr(n=None, order='default', **kwargs)

A discrete colour scale drawn from :func:depictr.palette.depictr_palette.

Parameters:

Name Type Description Default
n int

Number of colours to draw. By default, the scale takes as many colours as the plot has groups: the Okabe-Ito colours in order up to eight, and past eight a palette interpolated through them. Interpolation loses the colourblind-safety guarantee, which covers the eight base colours only, so :func:depictr.palette.depictr_palette warns past eight; facet the groups, or use the sequential ramp, when there are more. Pass n to fix the palette's length whatever the number of groups.

None
order ('default', 'contrast')

"contrast" draws the groups in the contrast-first order of :func:depictr.palette.depictr_palette, with which two or three groups pass every colour row of :func:depictr.check_figure. More than four groups warn when the plot is drawn. Added to one of depictr's own plots, the scale replaces the plot's colour scale.

"default"
**kwargs

Passed to :func:plotnine.scale_color_manual (for example name).

{}

Examples:

>>> import depictr as dp
>>> from plotnine import aes, geom_point, ggplot
>>> cy = dp.crop_yield()
>>> p = (ggplot(cy, aes("rainfall", "yield", colour="treatment"))
...      + geom_point() + dp.scale_colour_depictr())

The vermillion in the orange's place, at 3.87:1 against white:

>>> p = (ggplot(cy, aes("rainfall", "yield", colour="treatment",
...                     shape="treatment"))
...      + geom_point() + dp.scale_colour_depictr(order="contrast"))
Source code in src/depictr/theme.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
def scale_colour_depictr(n: int | None = None, order: str = "default",
                         **kwargs):
    """A discrete colour scale drawn from :func:`depictr.palette.depictr_palette`.

    Parameters
    ----------
    n : int, optional
        Number of colours to draw. By default, the scale takes as many colours
        as the plot has groups: the Okabe-Ito colours in order up to eight,
        and past eight a palette interpolated through them. Interpolation loses
        the colourblind-safety guarantee, which covers the eight base colours
        only, so :func:`depictr.palette.depictr_palette` warns past eight; facet
        the groups, or use the sequential ramp, when there are more. Pass ``n``
        to fix the palette's length whatever the number of groups.
    order : {"default", "contrast"}
        ``"contrast"`` draws the groups in the contrast-first order of
        :func:`depictr.palette.depictr_palette`, with which two or three
        groups pass every colour row of :func:`depictr.check_figure`. More than
        four groups warn when the plot is drawn. Added to one of depictr's own
        plots, the scale replaces the plot's colour scale.
    **kwargs
        Passed to :func:`plotnine.scale_color_manual` (for example ``name``).

    Examples
    --------
    >>> import depictr as dp
    >>> from plotnine import aes, geom_point, ggplot
    >>> cy = dp.crop_yield()
    >>> p = (ggplot(cy, aes("rainfall", "yield", colour="treatment"))
    ...      + geom_point() + dp.scale_colour_depictr())

    The vermillion in the orange's place, at 3.87:1 against white:

    >>> p = (ggplot(cy, aes("rainfall", "yield", colour="treatment",
    ...                     shape="treatment"))
    ...      + geom_point() + dp.scale_colour_depictr(order="contrast"))
    """
    kwargs.setdefault("na_value", NA_VALUE)
    return _sized_to_the_data(
        scale_color_manual(values=depictr_palette(n, order=order), **kwargs),
        n, order)

scale_color_depictr module-attribute

scale_color_depictr = scale_colour_depictr

scale_fill_depictr

scale_fill_depictr(n=None, order='default', **kwargs)

A discrete fill scale drawn from :func:depictr.palette.depictr_palette.

See :func:scale_colour_depictr for the parameters.

Examples:

>>> import depictr as dp
>>> from plotnine import aes, geom_bar, ggplot
>>> wb = dp.wellbeing_survey()
>>> p = (ggplot(wb, aes("education", fill="region"))
...      + geom_bar() + dp.scale_fill_depictr())
Source code in src/depictr/theme.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def scale_fill_depictr(n: int | None = None, order: str = "default", **kwargs):
    """A discrete fill scale drawn from :func:`depictr.palette.depictr_palette`.

    See :func:`scale_colour_depictr` for the parameters.

    Examples
    --------
    >>> import depictr as dp
    >>> from plotnine import aes, geom_bar, ggplot
    >>> wb = dp.wellbeing_survey()
    >>> p = (ggplot(wb, aes("education", fill="region"))
    ...      + geom_bar() + dp.scale_fill_depictr())
    """
    kwargs.setdefault("na_value", NA_VALUE)
    return _sized_to_the_data(
        scale_fill_manual(values=depictr_palette(n, order=order), **kwargs),
        n, order)

legend_inside

legend_inside(corner='top right')

Theme fragment that moves the legend into the plotting area.

Places the legend in a corner the plot's geometry usually leaves empty, on a solid white panel with a light border, so the figure needs no separate legend column. The functions that expose a legend_inside argument add this for you.

Parameters:

Name Type Description Default
corner ('top right', 'top left', 'bottom right', 'bottom left')

Which corner to use, or an explicit ((x, y), (jx, jy)) pair of position and justification in axis fractions.

"top right"

Returns:

Type Description
theme

Examples:

>>> import depictr as dp
>>> ld = dp.lexical_decision()
>>> p = dp.ecdf_plot(ld, "RT", group="condition") + dp.legend_inside("bottom right")
Source code in src/depictr/theme.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
def legend_inside(corner="top right"):
    """Theme fragment that moves the legend into the plotting area.

    Places the legend in a corner the plot's geometry usually leaves empty, on
    a solid white panel with a light border, so the figure needs no separate
    legend column.
    The functions that expose a ``legend_inside`` argument add this for you.

    Parameters
    ----------
    corner : {"top right", "top left", "bottom right", "bottom left"} or tuple
        Which corner to use, or an explicit ``((x, y), (jx, jy))`` pair of
        position and justification in axis fractions.

    Returns
    -------
    plotnine.theme

    Examples
    --------
    >>> import depictr as dp
    >>> ld = dp.lexical_decision()
    >>> p = dp.ecdf_plot(ld, "RT", group="condition") + dp.legend_inside("bottom right")
    """
    if isinstance(corner, str):
        if corner not in _LEGEND_CORNERS:
            raise ValueError(f"`corner` must be one of {list(_LEGEND_CORNERS)}.")
        position, justification = _LEGEND_CORNERS[corner]
    else:
        position, justification = corner
    return theme(
        legend_position=position,
        legend_justification=justification,
        # A solid white panel (not translucent) so the legend stays legible even
        # over a filled plot area, with a light border and delineated keys.
        legend_background=element_rect(fill="#ffffff", color="#cccccc", size=0.5),
        legend_key=element_rect(fill="#ffffff", color="#cccccc", size=0.3),
    )

depictr_palette

depictr_palette(n=None, kind='qualitative', order='default')

Return a depictr palette.

In the default order the second colour is the orange (#e69f00), at 2.25:1 against white. Any figure on white whose colour tells two or more groups apart therefore fails the geometry_contrast row of :func:depictr.check_figure. No more than three of the eight colours pass every colour row of that audit together.

order="contrast" puts those three first: the brand blue, the vermillion (#d55e00) and the reddish purple (#cc79a7), then the bluish green (#009e73) and the other four in their default order. Up to three groups then pass every colour row, contrast against white included. They stay at least 14.01 apart in CIEDE2000 under every vision type, 6.86 apart in lightness and 3.06:1 against white. Four groups keep 3:1 against white, but the bluish green and the vermillion are 3.31 apart in lightness, which fails greyscale_separability. Under this order, the second group is drawn in the vermillion, which is also :func:depictr.depictr_accent. The default order is unchanged, and the R twin gives the same order.

The R twin can make the order the default for every plot through its options. Python has none, so add dp.scale_colour_depictr(order="contrast"), or the fill scale, to each plot that should use it. plotnine then says that it replaces the plot's colour scale.

Parameters:

Name Type Description Default
n int

Number of colours to return, a single number of at least 1. A fraction is truncated, as in the R twin, so 2.7 gives two colours. For the qualitative palette None (the default) returns the full Okabe-Ito set, and an n larger than the available colours is interpolated. The sequential and diverging palettes are ramps and accept any n (defaulting to 7).

None
kind ('qualitative', 'sequential', 'diverging')

The palette family.

"qualitative"
order ('default', 'contrast')

The order of the qualitative palette. "contrast" gives the contrast-first order described above. The ramps have an order of their own and refuse "contrast".

"default"

Returns:

Type Description
list of str

Hex colour codes.

Raises:

Type Description
ValueError

For an n below 1 or not a number, an unknown kind or order, or order="contrast" with a ramp.

Warns:

Type Description
UserWarning

When n exceeds the eight Okabe-Ito colours and the qualitative palette is therefore interpolated. The colourblind-safety guarantee covers the eight base colours only; an interpolated palette fails :func:depictr.cvd.palette_safety. Facet the groups, or use the sequential ramp, when there are more than eight of them. Also when order="contrast" and n exceeds four, since the fifth colour, the orange, falls below 3:1 against white.

Examples:

>>> import depictr as dp
>>> dp.depictr_palette(3)
['#005b96', '#e69f00', '#009e73']
>>> dp.depictr_palette(3, order="contrast")
['#005b96', '#d55e00', '#cc79a7']
Source code in src/depictr/palette.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def depictr_palette(n: int | None = None, kind: str = "qualitative",
                    order: str = "default") -> list[str]:
    """Return a depictr palette.

    In the default order the second colour is the orange (``#e69f00``), at
    2.25:1 against white. Any figure on white whose colour tells two or more
    groups apart therefore fails the ``geometry_contrast`` row of
    :func:`depictr.check_figure`. No more than three of the eight colours pass
    every colour row of that audit together.

    ``order="contrast"`` puts those three first: the brand blue, the vermillion
    (``#d55e00``) and the reddish purple (``#cc79a7``), then the bluish green
    (``#009e73``) and the other four in their default order. Up to three groups
    then pass every colour row, contrast against white included. They stay at
    least 14.01 apart in CIEDE2000 under every vision type, 6.86 apart in
    lightness and 3.06:1 against white. Four groups keep 3:1 against white,
    but the bluish green and the vermillion are 3.31 apart in lightness, which
    fails ``greyscale_separability``. Under this order, the second group is
    drawn in the vermillion, which is also :func:`depictr.depictr_accent`.
    The default order is unchanged, and the R twin gives the same order.

    The R twin can make the order the default for every plot through its
    options. Python has none, so add
    ``dp.scale_colour_depictr(order="contrast")``, or the fill scale, to each
    plot that should use it. plotnine then says that it replaces the plot's
    colour scale.

    Parameters
    ----------
    n : int, optional
        Number of colours to return, a single number of at least 1. A
        fraction is truncated, as in the R twin, so 2.7 gives two colours. For
        the qualitative palette ``None`` (the default) returns the full
        Okabe-Ito set, and an ``n`` larger than the available colours is
        interpolated. The sequential and diverging palettes are ramps and
        accept any ``n`` (defaulting to 7).
    kind : {"qualitative", "sequential", "diverging"}
        The palette family.
    order : {"default", "contrast"}
        The order of the qualitative palette. ``"contrast"`` gives the
        contrast-first order described above. The ramps have an order of their
        own and refuse ``"contrast"``.

    Returns
    -------
    list of str
        Hex colour codes.

    Raises
    ------
    ValueError
        For an ``n`` below 1 or not a number, an unknown ``kind`` or
        ``order``, or ``order="contrast"`` with a ramp.

    Warns
    -----
    UserWarning
        When ``n`` exceeds the eight Okabe-Ito colours and the qualitative
        palette is therefore interpolated. The colourblind-safety guarantee
        covers the eight base colours only; an interpolated palette fails
        :func:`depictr.cvd.palette_safety`. Facet the groups, or use the
        sequential ramp, when there are more than eight of them. Also when
        ``order="contrast"`` and ``n`` exceeds four, since the fifth colour,
        the orange, falls below 3:1 against white.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.depictr_palette(3)
    ['#005b96', '#e69f00', '#009e73']
    >>> dp.depictr_palette(3, order="contrast")
    ['#005b96', '#d55e00', '#cc79a7']
    """
    if n is not None:
        # depictr_palette(-1) gave seven colours and (0) none, and a fraction
        # failed inside the slicing or the ramp. The R twin's check and message.
        if (isinstance(n, bool) or not isinstance(n, numbers.Real)
                or not math.isfinite(n) or n < 1):
            raise ValueError("`n` must be a single positive number.")
        n = int(n)
    if order not in ("default", "contrast"):
        raise ValueError("`order` must be 'default' or 'contrast'.")
    if kind in ("sequential", "diverging") and order != "default":
        raise ValueError(
            f"`order` applies to the qualitative palette, not to the {kind} "
            f"ramp."
        )
    if kind == "sequential":
        return _ramp(_SEQUENTIAL_ANCHORS, n or 7)
    if kind == "diverging":
        return _ramp(_DIVERGING_ANCHORS, n or 7)
    if kind != "qualitative":
        raise ValueError(
            "`kind` must be 'qualitative', 'sequential' or 'diverging'."
        )
    base = CONTRAST_FIRST if order == "contrast" else OKABE_ITO
    if n is None:
        return list(base)
    if order == "contrast" and n > _CONTRAST_KEPT:
        warnings.warn(
            "Past four colours the contrast-first order no longer keeps every "
            "colour at 3:1 against white.",
            stacklevel=2,
        )
    if n <= len(base):
        return base[:n]
    # More categories than base colours: interpolate through the set so groups
    # stay as distinct as the space allows. "As the space allows" is no longer
    # far enough apart to be colourblind-safe, and the package says so rather
    # than handing back colours under a guarantee it cannot keep.
    warnings.warn(
        f"{n} colours interpolated through the {len(OKABE_ITO)}-colour "
        f"Okabe-Ito set: the colour-vision-deficiency guarantee holds only up "
        f"to {len(OKABE_ITO)} categories. Use a sequential palette, or facet "
        f"the groups, if the distinction must survive colour-vision "
        f"deficiency.",
        stacklevel=2,
    )
    return _ramp(base, n)

depictr_brand

depictr_brand()

Return the depictr brand colour as a hex string.

Examples:

>>> import depictr as dp
>>> dp.depictr_brand()
'#005b96'
Source code in src/depictr/palette.py
76
77
78
79
80
81
82
83
84
85
def depictr_brand() -> str:
    """Return the depictr brand colour as a hex string.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.depictr_brand()
    '#005b96'
    """
    return BRAND

depictr_accent

depictr_accent()

Return the depictr accent colour as a hex string.

The accent marks a single element against the brand blue, such as the flagged points of :func:depictr.outlier_plot and the missing cells of :func:depictr.missingness_map. It is the Okabe-Ito vermillion, as in the R twin, which stays distinguishable from the brand blue under the common colour-vision deficiencies and reaches 3.87:1 against white.

Examples:

>>> import depictr as dp
>>> dp.depictr_accent()
'#d55e00'
Source code in src/depictr/palette.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def depictr_accent() -> str:
    """Return the depictr accent colour as a hex string.

    The accent marks a single element against the brand blue, such as the
    flagged points of :func:`depictr.outlier_plot` and the missing cells of
    :func:`depictr.missingness_map`. It is the Okabe-Ito vermillion, as in the
    R twin, which stays distinguishable from the brand blue under the common
    colour-vision deficiencies and reaches 3.87:1 against white.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.depictr_accent()
    '#d55e00'
    """
    return ACCENT

check_figure

check_figure(plot, width_cm=17.78, render_width_cm=17.78, min_delta_e=5, min_text_pt=6, metric='ciede2000', max_text_pt=None)

Audit a finished figure for accessibility and honesty.

Checks a figure as it will be submitted, rather than the palette it was built from. Every row carries the value it measured next to the threshold it was measured against, so a verdict can be argued with rather than merely accepted. A check passes when the measured value is at least the threshold. The one exception is text_size_max, a ceiling added when max_text_pt is given, which passes when the measured value is at most its threshold. A check with nothing to measure, such as colour separability on a figure that encodes nothing by colour, reports NaN and a verdict of "not applicable" instead of a free pass.

Colours and text are measured as drawn. A translucent colour is composited over the background it is drawn on with the alpha it is drawn with, so it is measured as the paler colour the reader sees. As plotnine draws them, bars, tiles, areas, densities, ribbons, polygons, violins, boxplots, crossbars and smooths apply a layer's alpha to their fill and leave the outline opaque. Lines, paths, segments, error bars and reference lines apply it to their colour, and points apply it to both. Any other geom is taken to apply it to both, the cautious assumption. A colour that carries its own alpha keeps it, as plotnine draws it. A colour drawn at several alphas within one layer is measured at the smallest. Where marks overlap one another, the colour that shows depends on what lies beneath it, and that is not measured.

colour_separability is the smallest colour difference between two of the figure's encoding colours, by the CIEDE2000 formula (Sharma, Wu & Dalal, 2005) unless metric asks for the older CIE76. The three colour_separability_* rows repeat that measurement after simulating each dichromacy at full severity with :func:depictr.simulate_cvd. Encoding colours are the distinct colour and fill values that tell groups apart. A layer contributes its colours when it maps colour or fill to a discrete scale, even if it draws a single colour, or when it draws two or more. Groups drawn one per layer are therefore audited as they would be in one layer. A colour set as a constant, as in geom_point(colour="red"), is left out, even when another layer sets a different one, and so is a lone colour passed through scale_colour_identity(). The audit cannot tell such a colour from decoration, so a colour meant to tell groups apart should be mapped, as aes(colour="'treated'") maps a constant, with scale_colour_manual() to fix its values. The outline colours, across every layer, are compared among themselves, and so are the fill colours, and the row reports the closest pair from either set. An outline is therefore never compared with its own fill, nor a group with itself as another layer draws it.

A continuous colour or fill scale, such as a heatmap's, is a smooth ramp rather than a set of codes, and none of the colour rows audits it. When nothing else in the figure encodes by colour, each of them abstains with a detail that says so. Whether a ramp reads in order, to a reader with a colour-vision deficiency and in greyscale as well, depends on how evenly its lightness changes, which the audit does not measure. Prefer a perceptually ordered map, such as viridis or depictr's sequential and diverging ramps, over a rainbow (Crameri et al., 2020).

greyscale_separability is the smallest difference in CIE lightness between those same colours, which is what survives printing in black and white. It is the absolute difference in lightness, whichever metric the colour rows use.

text_size is the smallest point size of the text the theme draws around the panel: axis labels and titles, legends, facet strips, titles and captions. It is scaled by width_cm / render_width_cm, so a figure drawn seven inches wide and printed in an 8.9 cm column has every point size halved. Text drawn inside the panel is measured by annotation_text_size.

text_contrast is the WCAG 2.2 contrast ratio of the text that falls furthest short of its own threshold. Each text is measured against the background it is drawn on: a facet strip's fill for strip text, the legend box's fill for legend text, and the plot background for the rest. A translucent fill is measured as the colour it shows over what lies beneath it, and so is text drawn with an alpha. Text that is large at its printed size, 18 pt or 14 pt in bold, is held to 3:1, and other text to 4.5:1, as success criterion 1.4.3 asks. The row's threshold is the one that applies to the text it reports, and its detail names that text's colour, background, printed size and weight.

geometry_contrast is the smallest WCAG 2.2 contrast ratio of any encoded group's drawn colour against the panel background, or against the plot background where the panel paints none. Where a layer maps its outline colour to the same groups as its fill, each group counts the better of the two, since WCAG lets a border carry a graphical object's contrast. For a translucent colour, the detail names the colour, its alpha and the colour it is drawn as.

redundant_encoding counts how many of shape and line type also vary across the layers that draw the encoding colours. They are gathered as the colours are. A layer contributes the values it maps to a discrete scale, or the two or more it draws, and a shape or line type set as a constant is left out. Zero means the distinction between groups is carried by colour alone, the single most common way an otherwise careful figure becomes unreadable.

annotation_text_size is the smallest point size of the text drawn inside the panel, scaled as text_size is and held to the same min_text_pt. That text comes from layers such as geom_text and geom_label, from annotations, and from depictr's own cell values and labels. Its contrast is not measured, since it sits on the marks rather than on one background.

text_size_max is reported only when max_text_pt is given, as the last row. It is the largest point size of all the text measured, around the panel and inside it, scaled as text_size is. It is the one row that passes at or below its threshold.

Limitations of the default palette. The eight-colour qualitative palette clears the colour-separability checks comfortably and fails greyscale_separability: its orange (#e69f00) and sky blue (#56b4e9) differ by only 0.79 in lightness, so they print as the same grey. The colourblind-safety guarantee depictr makes is about hue confusion, and it was never a claim about greyscale. A figure that may be printed in black and white should use fewer groups, or a sequential palette, or add a redundant shape or line type; the four leading colours are not safe in greyscale either, since the bluish green and the vermillion differ by 3.55. The threshold has been left where it is rather than moved to let the package's own defaults through.

The palette falls short on contrast as well. Four of its eight colours are below the 3:1 against white that WCAG asks of a graphical object: the orange at 2.25:1, the sky blue at 2.31:1, the grey at 2.85:1 and the yellow at 1.32:1. The orange is the second colour, so a default figure on white whose colour tells two or more groups apart fails geometry_contrast, and drawing its marks translucent lowers the ratio further. Opaque marks, the vermillion (#d55e00, at 3.87:1) in the orange's place and a redundant shape clear that row and redundant_encoding, as the first example below shows. The contrast-first order of :func:depictr.depictr_palette puts the vermillion second and the reddish purple third, and :func:depictr.scale_colour_depictr takes it as order="contrast". Up to three groups then pass every colour row, and four keep 3:1 against white.

Auditing at the printed size. Text is drawn in points, so the size it prints at depends on the two widths. A figure drawn at the size it will be printed, as :func:depictr.save_plot draws one given width=8.9, units="cm", needs no scaling. Pass the saved width as both width_cm and render_width_cm, or leave both at their defaults. Given alone, width_cm audits the figure as though it had been drawn seven inches wide and then shrunk to fit. :func:depictr.save_plot with check=True audits a figure at the width it saves it at, given as both.

The default min_text_pt of 6 is lenient. As their guidelines stood on 30 September 2026, PLOS asked for text of 8 to 12 pt and APA Style for 8 to 14 pt. Elsevier asked for 7 pt as a rule of thumb, and Nature for 5 to 7 pt, with panel labels at 8 pt. Pass the floor of the journal the figure is for as min_text_pt, and its ceiling, where it sets one, as max_text_pt. Nature's 7 pt applies to all text but the panel labels, and a panel label drawn as a panel's title or annotation is measured with the rest. The four pages are listed under References.

Compositions. A composition, such as :func:depictr.arrange_plots, :func:depictr.residual_diagnostics_plot and :func:depictr.model_report return, is audited panel by panel. Each panel has its own scales, theme and text, so each is audited as it would be on its own. The reports are stacked under a leading integer column, panel, which only the report of a composition has. The widths are those of the whole composition. A panel's text keeps its point size whatever share of the width the panel takes, so the text of every panel scales by the same ratio.

Panels are numbered depth-first through any composition nested inside, in the order the composition holds them, which is the reading order of the grids :func:depictr.arrange_plots builds. A plot_spacer() draws no figure of its own and takes no number. A panel's legend is measured with the panel. Only the panels are measured, so anything a composition draws around them is not. The R twin numbers the panels of a patchwork by the same rule. The panel tags :func:depictr.arrange_plots adds belong to the panels' own plots in this twin, so each tag is measured with its panel, by text_size_max too. The R twin's patchwork draws its tags around the panels, where they are not measured, so a composition arranged with tag_levels=None is the one whose report matches the R twin's.

Parameters:

Name Type Description Default
plot ggplot or Compose

A plot, as returned by any depictr plotting function, including one extended afterwards with +, or a multi-panel composition (see "Compositions" above).

required
width_cm float

The width, in centimetres, that the figure will occupy in the finished document. Defaults to 17.78 cm, the seven inches :func:depictr.save_plot draws at, which means no scaling.

17.78
render_width_cm float

The width, in centimetres, that the figure is drawn at. Defaults to the same 17.78 cm. The ratio of the two widths is the factor every point size is multiplied by.

17.78
min_delta_e float

The smallest acceptable colour difference, on the scale of metric, for the colour separability checks, and the smallest acceptable difference in lightness for the greyscale check. Defaults to 5, matching :func:depictr.palette_safety.

5
min_text_pt float

The smallest acceptable printed text size, in points, for the text around the panel and the text inside it alike. Defaults to 6, below the floor most journals set (see "Auditing at the printed size" above).

6
metric ('ciede2000', 'cie76')

The colour difference the colour separability checks measure: CIEDE2000, the default, or the Euclidean distance in CIELAB that earlier versions measured. "cie76" is kept so that earlier results can be reproduced, and a later release will remove it.

"ciede2000"
max_text_pt float

The largest acceptable printed text size, in points, or None, the default, for no ceiling. When given, a text_size_max row is appended (see text_size_max above).

None

Returns:

Type Description
DataFrame

One row per check, with columns check, measured, threshold, verdict ("pass", "fail" or "not applicable") and detail, a short note naming what produced the measurement. There are ten rows, and an eleventh, text_size_max, when max_text_pt is given. For a composition, the rows of each panel in turn, after a leading integer column panel.

See Also

depictr.palette_safety : the palette in the abstract, rather than a figure.

References

APA Style. Figures. https://apastyle.apa.org/style-grammar-guidelines/tables-figures/figures

Crameri, F., Shephard, G. E., & Heron, P. J. (2020). The misuse of colour in science communication. Nature Communications, 11(1), 5444. https://doi.org/10.1038/s41467-020-19160-7

Elsevier. Artwork sizing. https://www.elsevier.com/about/policies-and-standards/author/artwork-and-media-instructions/artwork-sizing

Nature. Guide to preparing final artwork. https://www.nature.com/documents/nature-final-artwork.pdf

PLOS ONE. Figures. https://journals.plos.org/plosone/s/figures

Sharma, G., Wu, W., & Dalal, E. N. (2005). The CIEDE2000 color-difference formula: Implementation notes, supplementary test data, and mathematical observations. Color Research & Application, 30(1), 21-30. https://doi.org/10.1002/col.20070

Examples:

>>> import depictr as dp
>>> from plotnine import aes, geom_point, ggplot, scale_colour_manual
>>> cy = dp.crop_yield()
>>> good = (ggplot(cy, aes("rainfall", "yield", colour="treatment",
...                        shape="treatment"))
...         + geom_point()
...         + scale_colour_manual(values=["#005b96", "#d55e00"])
...         + dp.theme_depictr())

Every check passes but the one for text inside the panel, since the figure draws none there.

>>> report = dp.check_figure(good)
>>> report.loc[report["verdict"] != "pass", "check"].tolist()
['annotation_text_size']

The same figure destined for an 8.9 cm journal column, where the text is half the size it looks on screen.

>>> shrunk = dp.check_figure(good, width_cm=8.9)
>>> shrunk.loc[shrunk["check"] == "text_size", "verdict"].item()
'fail'

Saved at 8.9 cm in the first place, with save_plot(width=8.9, units="cm"), it is drawn at the size it prints, so both widths are 8.9.

>>> saved = dp.check_figure(good, width_cm=8.9, render_width_cm=8.9)
>>> saved.loc[saved["check"] == "text_size", "verdict"].item()
'pass'

Nature allowed no text above 7 pt but the panel labels. The axis titles here are 11 pt, so the ceiling row fails.

>>> nature = dp.check_figure(good, max_text_pt=7)
>>> nature.loc[nature["check"] == "text_size_max",
...            ["measured", "verdict"]].values.tolist()
[[11.0, 'fail']]

Each panel of a composition is audited, and a column says which panel each row describes. At base size 5, the second panel's text is too small.

>>> small = good + dp.theme_depictr(base_size=5)
>>> both = dp.check_figure(dp.arrange_plots(good, small))
>>> both.loc[both["verdict"] == "fail", ["panel", "check"]].values.tolist()
[[2, 'text_size']]
Source code in src/depictr/audit.py
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
def check_figure(plot, width_cm: float = 17.78, render_width_cm: float = 17.78,
                 min_delta_e: float = 5, min_text_pt: float = 6,
                 metric: str = "ciede2000",
                 max_text_pt: float | None = None) -> pd.DataFrame:
    """Audit a finished figure for accessibility and honesty.

    Checks a figure as it will be submitted, rather than the palette it was
    built from. Every row carries the value it measured next to the threshold it
    was measured against, so a verdict can be argued with rather than merely
    accepted. A check passes when the measured value is at least the threshold.
    The one exception is ``text_size_max``, a ceiling added when
    ``max_text_pt`` is given, which passes when the measured value is at most
    its threshold. A check with nothing to measure, such as colour separability
    on a figure that encodes nothing by colour, reports ``NaN`` and a verdict
    of ``"not applicable"`` instead of a free pass.

    Colours and text are measured as drawn. A translucent colour is composited
    over the background it is drawn on with the alpha it is drawn with, so it
    is measured as the paler colour the reader sees. As plotnine draws them,
    bars, tiles, areas, densities, ribbons, polygons, violins, boxplots,
    crossbars and smooths apply a layer's alpha to their fill and leave the
    outline opaque. Lines, paths, segments, error bars and reference lines
    apply it to their colour, and points apply it to both. Any other geom is
    taken to apply it to both, the cautious assumption. A colour that carries
    its own alpha keeps it, as plotnine draws it. A colour drawn at several
    alphas within one layer is measured at the smallest. Where marks overlap
    one another, the colour that shows depends on what lies beneath it, and
    that is not measured.

    ``colour_separability`` is the smallest colour difference between two of
    the figure's encoding colours, by the CIEDE2000 formula (Sharma, Wu &
    Dalal, 2005) unless ``metric`` asks for the older CIE76. The three
    ``colour_separability_*`` rows repeat that measurement after simulating each
    dichromacy at full severity with :func:`depictr.simulate_cvd`. Encoding
    colours are the distinct colour and fill values that tell groups apart. A
    layer contributes its colours when it maps colour or fill to a discrete
    scale, even if it draws a single colour, or when it draws two or more.
    Groups drawn one per layer are therefore audited as they would be in one
    layer. A colour set as a constant, as in ``geom_point(colour="red")``, is
    left out, even when another layer sets a different one, and so is a lone
    colour passed through ``scale_colour_identity()``. The audit cannot tell
    such a colour from decoration, so a colour meant to tell groups apart
    should be mapped, as ``aes(colour="'treated'")`` maps a constant, with
    ``scale_colour_manual()`` to fix its values. The outline colours, across
    every layer, are compared among themselves, and so are the fill colours,
    and the row reports the closest pair from either set. An outline is
    therefore never compared with its own fill, nor a group with itself as
    another layer draws it.

    A continuous colour or fill scale, such as a heatmap's, is a smooth ramp
    rather than a set of codes, and none of the colour rows audits it. When
    nothing else in the figure encodes by colour, each of them abstains with a
    ``detail`` that says so. Whether a ramp reads in order, to a reader with a
    colour-vision deficiency and in greyscale as well, depends on how evenly
    its lightness changes, which the audit does not measure. Prefer a
    perceptually ordered map, such as viridis or depictr's sequential and
    diverging ramps, over a rainbow (Crameri et al., 2020).

    ``greyscale_separability`` is the smallest difference in CIE lightness
    between those same colours, which is what survives printing in black and
    white. It is the absolute difference in lightness, whichever ``metric`` the
    colour rows use.

    ``text_size`` is the smallest point size of the text the theme draws around
    the panel: axis labels and titles, legends, facet strips, titles and
    captions. It is scaled by ``width_cm / render_width_cm``, so a figure drawn
    seven inches wide and printed in an 8.9 cm column has every point size
    halved. Text drawn inside the panel is measured by
    ``annotation_text_size``.

    ``text_contrast`` is the WCAG 2.2 contrast ratio of the text that falls
    furthest short of its own threshold. Each text is measured against the
    background it is drawn on: a facet strip's fill for strip text, the legend
    box's fill for legend text, and the plot background for the rest. A
    translucent fill is measured as the colour it shows over what lies beneath
    it, and so is text drawn with an alpha. Text that is large at its printed
    size, 18 pt or 14 pt in bold, is held to 3:1, and other text to 4.5:1, as
    success criterion 1.4.3 asks. The row's ``threshold`` is the one that
    applies to the text it reports, and its ``detail`` names that text's
    colour, background, printed size and weight.

    ``geometry_contrast`` is the smallest WCAG 2.2 contrast ratio of any
    encoded group's drawn colour against the panel background, or against the
    plot background where the panel paints none. Where a layer maps its outline
    colour to the same groups as its fill, each group counts the better of the
    two, since WCAG lets a border carry a graphical object's contrast. For a
    translucent colour, the ``detail`` names the colour, its alpha and the
    colour it is drawn as.

    ``redundant_encoding`` counts how many of shape and line type also vary
    across the layers that draw the encoding colours. They are gathered as the
    colours are. A layer contributes the values it maps to a discrete scale, or
    the two or more it draws, and a shape or line type set as a constant is
    left out. Zero means the distinction between groups is carried by colour
    alone, the single most common way an otherwise careful figure becomes
    unreadable.

    ``annotation_text_size`` is the smallest point size of the text drawn
    inside the panel, scaled as ``text_size`` is and held to the same
    ``min_text_pt``. That text comes from layers such as ``geom_text`` and
    ``geom_label``, from annotations, and from depictr's own cell values and
    labels. Its contrast is not measured, since it sits on the marks rather
    than on one background.

    ``text_size_max`` is reported only when ``max_text_pt`` is given, as the
    last row. It is the largest point size of all the text measured, around
    the panel and inside it, scaled as ``text_size`` is. It is the one row
    that passes at or below its threshold.

    **Limitations of the default palette.** The eight-colour qualitative
    palette clears the colour-separability checks comfortably and fails
    ``greyscale_separability``: its orange (``#e69f00``) and sky blue
    (``#56b4e9``) differ by only 0.79 in lightness, so they print as the same
    grey. The colourblind-safety guarantee depictr makes is about hue confusion,
    and it was never a claim about greyscale. A figure that may be printed in
    black and white should use fewer groups, or a sequential palette, or add a
    redundant shape or line type; the four leading colours are not safe in
    greyscale either, since the bluish green and the vermillion differ by 3.55.
    The threshold has been left where it is rather than moved to let the
    package's own defaults through.

    The palette falls short on contrast as well. Four of its eight colours are
    below the 3:1 against white that WCAG asks of a graphical object: the
    orange at 2.25:1, the sky blue at 2.31:1, the grey at 2.85:1 and the yellow
    at 1.32:1. The orange is the second colour, so a default figure on white
    whose colour tells two or more groups apart fails ``geometry_contrast``,
    and drawing its marks translucent lowers the ratio further. Opaque marks,
    the vermillion (``#d55e00``, at 3.87:1) in the orange's place and a
    redundant shape clear that row and ``redundant_encoding``, as the first
    example below shows. The contrast-first order of
    :func:`depictr.depictr_palette` puts the vermillion second and the reddish
    purple third, and :func:`depictr.scale_colour_depictr` takes it as
    ``order="contrast"``. Up to three groups then pass every colour row, and
    four keep 3:1 against white.

    **Auditing at the printed size.** Text is drawn in points, so the size it
    prints at depends on the two widths. A figure drawn at the size it will be
    printed, as :func:`depictr.save_plot` draws one given
    ``width=8.9, units="cm"``, needs no scaling. Pass the saved width as both
    ``width_cm`` and ``render_width_cm``, or leave both at their defaults.
    Given alone, ``width_cm`` audits the figure as though it had been drawn
    seven inches wide and then shrunk to fit. :func:`depictr.save_plot` with
    ``check=True`` audits a figure at the width it saves it at, given as both.

    The default ``min_text_pt`` of 6 is lenient. As their guidelines stood on
    30 September 2026, PLOS asked for text of 8 to 12 pt and APA Style for 8 to
    14 pt. Elsevier asked for 7 pt as a rule of thumb, and Nature for 5 to 7
    pt, with panel labels at 8 pt. Pass the floor of the journal the figure is
    for as ``min_text_pt``, and its ceiling, where it sets one, as
    ``max_text_pt``. Nature's 7 pt applies to all text but the panel labels,
    and a panel label drawn as a panel's title or annotation is measured with
    the rest. The four pages are listed under References.

    **Compositions.** A composition, such as :func:`depictr.arrange_plots`,
    :func:`depictr.residual_diagnostics_plot` and :func:`depictr.model_report`
    return, is audited panel by panel. Each panel has its own scales, theme
    and text, so each is audited as it would be on its own. The reports are
    stacked under a leading integer column, ``panel``, which only the report
    of a composition has. The widths are those of the whole composition.
    A panel's text keeps its point size whatever share of the width the panel
    takes, so the text of every panel scales by the same ratio.

    Panels are numbered depth-first through any composition nested inside, in
    the order the composition holds them, which is the reading order of the
    grids :func:`depictr.arrange_plots` builds. A ``plot_spacer()`` draws no
    figure of its own and takes no number. A panel's legend is measured with
    the panel. Only the panels are measured, so anything a composition draws
    around them is not. The R twin numbers the panels of a patchwork by the
    same rule. The panel tags :func:`depictr.arrange_plots` adds belong to the
    panels' own plots in this twin, so each tag is measured with its panel,
    by ``text_size_max`` too. The R twin's patchwork draws its tags around the
    panels, where they are not measured, so a composition arranged with
    ``tag_levels=None`` is the one whose report matches the R twin's.

    Parameters
    ----------
    plot : plotnine.ggplot or plotnine.composition.Compose
        A plot, as returned by any depictr plotting function, including one
        extended afterwards with ``+``, or a multi-panel composition (see
        "Compositions" above).
    width_cm : float
        The width, in centimetres, that the figure will occupy in the finished
        document. Defaults to 17.78 cm, the seven inches
        :func:`depictr.save_plot` draws at, which means no scaling.
    render_width_cm : float
        The width, in centimetres, that the figure is drawn at. Defaults to the
        same 17.78 cm. The ratio of the two widths is the factor every point
        size is multiplied by.
    min_delta_e : float
        The smallest acceptable colour difference, on the scale of ``metric``,
        for the colour separability checks, and the smallest acceptable
        difference in lightness for the greyscale check. Defaults to 5,
        matching :func:`depictr.palette_safety`.
    min_text_pt : float
        The smallest acceptable printed text size, in points, for the text
        around the panel and the text inside it alike. Defaults to 6, below the
        floor most journals set (see "Auditing at the printed size" above).
    metric : {"ciede2000", "cie76"}
        The colour difference the colour separability checks measure:
        CIEDE2000, the default, or the Euclidean distance in CIELAB that
        earlier versions measured. ``"cie76"`` is kept so that earlier results
        can be reproduced, and a later release will remove it.
    max_text_pt : float, optional
        The largest acceptable printed text size, in points, or None, the
        default, for no ceiling. When given, a ``text_size_max`` row is
        appended (see ``text_size_max`` above).

    Returns
    -------
    pandas.DataFrame
        One row per check, with columns ``check``, ``measured``, ``threshold``,
        ``verdict`` (``"pass"``, ``"fail"`` or ``"not applicable"``) and
        ``detail``, a short note naming what produced the measurement. There
        are ten rows, and an eleventh, ``text_size_max``, when ``max_text_pt``
        is given. For a composition, the rows of each panel in turn, after a
        leading integer column ``panel``.

    See Also
    --------
    depictr.palette_safety : the palette in the abstract, rather than a figure.

    References
    ----------
    APA Style. Figures.
    https://apastyle.apa.org/style-grammar-guidelines/tables-figures/figures

    Crameri, F., Shephard, G. E., & Heron, P. J. (2020). The misuse of colour
    in science communication. Nature Communications, 11(1), 5444.
    https://doi.org/10.1038/s41467-020-19160-7

    Elsevier. Artwork sizing.
    https://www.elsevier.com/about/policies-and-standards/author/artwork-and-media-instructions/artwork-sizing

    Nature. Guide to preparing final artwork.
    https://www.nature.com/documents/nature-final-artwork.pdf

    PLOS ONE. Figures. https://journals.plos.org/plosone/s/figures

    Sharma, G., Wu, W., & Dalal, E. N. (2005). The CIEDE2000 color-difference
    formula: Implementation notes, supplementary test data, and mathematical
    observations. Color Research & Application, 30(1), 21-30.
    https://doi.org/10.1002/col.20070

    Examples
    --------
    >>> import depictr as dp
    >>> from plotnine import aes, geom_point, ggplot, scale_colour_manual
    >>> cy = dp.crop_yield()
    >>> good = (ggplot(cy, aes("rainfall", "yield", colour="treatment",
    ...                        shape="treatment"))
    ...         + geom_point()
    ...         + scale_colour_manual(values=["#005b96", "#d55e00"])
    ...         + dp.theme_depictr())

    Every check passes but the one for text inside the panel, since the figure
    draws none there.

    >>> report = dp.check_figure(good)
    >>> report.loc[report["verdict"] != "pass", "check"].tolist()
    ['annotation_text_size']

    The same figure destined for an 8.9 cm journal column, where the text is
    half the size it looks on screen.

    >>> shrunk = dp.check_figure(good, width_cm=8.9)
    >>> shrunk.loc[shrunk["check"] == "text_size", "verdict"].item()
    'fail'

    Saved at 8.9 cm in the first place, with ``save_plot(width=8.9,
    units="cm")``, it is drawn at the size it prints, so both widths are 8.9.

    >>> saved = dp.check_figure(good, width_cm=8.9, render_width_cm=8.9)
    >>> saved.loc[saved["check"] == "text_size", "verdict"].item()
    'pass'

    Nature allowed no text above 7 pt but the panel labels. The axis titles
    here are 11 pt, so the ceiling row fails.

    >>> nature = dp.check_figure(good, max_text_pt=7)
    >>> nature.loc[nature["check"] == "text_size_max",
    ...            ["measured", "verdict"]].values.tolist()
    [[11.0, 'fail']]

    Each panel of a composition is audited, and a column says which panel
    each row describes. At base size 5, the second panel's text is too small.

    >>> small = good + dp.theme_depictr(base_size=5)
    >>> both = dp.check_figure(dp.arrange_plots(good, small))
    >>> both.loc[both["verdict"] == "fail", ["panel", "check"]].values.tolist()
    [[2, 'text_size']]
    """
    from plotnine import ggplot
    from plotnine.composition import Compose

    if not isinstance(plot, (ggplot, Compose)):
        raise TypeError(
            "`plot` must be a plot object, as returned by any depictr plotting "
            "function."
        )
    _positive_scalar(width_cm, "width_cm")
    _positive_scalar(render_width_cm, "render_width_cm")
    _positive_scalar(min_delta_e, "min_delta_e")
    _positive_scalar(min_text_pt, "min_text_pt")
    if max_text_pt is not None:
        _positive_scalar(max_text_pt, "max_text_pt")
    _check_metric(metric)

    settings = (width_cm, render_width_cm, min_delta_e, min_text_pt, metric,
                max_text_pt)
    if isinstance(plot, Compose):
        return _audit_composition(plot, *settings)
    return _audit_plot(plot, *settings)

palette_safety

palette_safety(colours=None, threshold=5.0, metric='ciede2000')

Check that a palette stays distinguishable under each deficiency.

For normal vision and each deficiency at full severity, the palette's colours are converted to CIELAB and the smallest colour difference between any two of them is found. The difference is CIEDE2000 (Sharma, Wu & Dalal, 2005) unless metric asks for the older measure. The lower this minimum, the more likely two categories are to be confused. A palette is reported safe when the minimum across all conditions is at least threshold.

Earlier versions measured the Euclidean distance in CIELAB, known as CIE76, which overstates the difference between saturated colours: the blues #005b96 and #0060a8 are 8.20 apart by CIE76 and 2.69 by CIEDE2000. ggplot2's default palette of five colours passed the check under CIE76, although under deuteranopia its sky blue and its pink come within 2.74 of each other in CIEDE2000.

The default threshold of 5.0 is calibrated against the reference colourblind-safe palette: the Okabe-Ito set's tightest pair (reddish-purple against grey) sits at 6.43 under full deuteranopia, so the cut must lie below that to pass the recommended palette, while still flagging colours that become near-identical under a deficiency. The difference includes lightness, which survives colour-vision deficiency, so two colours that share a hue but differ in lightness are correctly treated as distinguishable. Full severity is the worst case; most colour-vision deficiency is milder.

Parameters:

Name Type Description Default
colours str or list of str

The palette to test. Defaults to the depictr qualitative palette. Needs at least two colours: a pairwise distance over fewer than two has no value, not an infinitely safe one. A single string is one colour, so it raises with that message.

None
threshold float

The smallest acceptable colour difference, on the scale of metric.

5.0
metric ('ciede2000', 'cie76')

The colour difference to measure: CIEDE2000, the default, or the Euclidean distance in CIELAB that earlier versions measured. "cie76" is kept so that earlier results can be reproduced, and a later release will remove it.

"ciede2000"

Returns:

Type Description
dict

min_delta_e (the worst case across conditions), by_condition (the minimum colour difference for normal vision and each deficiency), worst_condition and worst_pair (the closest colours and where), safe (whether min_delta_e meets threshold) and threshold.

See Also

depictr.check_figure : the same question asked of a finished figure, which uses only as many colours as it has groups and has text and a background besides.

References

Sharma, G., Wu, W., & Dalal, E. N. (2005). The CIEDE2000 color-difference formula: Implementation notes, supplementary test data, and mathematical observations. Color Research & Application, 30(1), 21-30. https://doi.org/10.1002/col.20070

Examples:

>>> import depictr as dp
>>> dp.palette_safety()['safe']
True

ggplot2's default palette of five colours, which passed under CIE76.

>>> hue5 = ["#F8766D", "#A3A500", "#00BF7D", "#00B0F6", "#E76BF3"]
>>> dp.palette_safety(hue5)['safe'], dp.palette_safety(hue5, metric="cie76")['safe']
(False, True)
Source code in src/depictr/cvd.py
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
def palette_safety(colours: str | list[str] | None = None, threshold: float = 5.0,
                   metric: str = "ciede2000") -> dict:
    """Check that a palette stays distinguishable under each deficiency.

    For normal vision and each deficiency at full severity, the palette's
    colours are converted to CIELAB and the smallest colour difference between
    any two of them is found. The difference is CIEDE2000 (Sharma, Wu & Dalal,
    2005) unless ``metric`` asks for the older measure. The lower this minimum,
    the more likely two categories are to be confused. A palette is reported
    safe when the minimum across all conditions is at least ``threshold``.

    Earlier versions measured the Euclidean distance in CIELAB, known as CIE76,
    which overstates the difference between saturated colours: the blues
    ``#005b96`` and ``#0060a8`` are 8.20 apart by CIE76 and 2.69 by CIEDE2000.
    ggplot2's default palette of five colours passed the check under CIE76,
    although under deuteranopia its sky blue and its pink come within 2.74 of
    each other in CIEDE2000.

    The default ``threshold`` of 5.0 is calibrated against the reference
    colourblind-safe palette: the Okabe-Ito set's tightest pair (reddish-purple
    against grey) sits at 6.43 under full deuteranopia, so the cut must lie
    below that to pass the recommended palette, while still flagging colours that
    become near-identical under a deficiency. The difference includes lightness,
    which survives colour-vision deficiency, so two colours that share a hue but
    differ in lightness are correctly treated as distinguishable. Full severity
    is the worst case; most colour-vision deficiency is milder.

    Parameters
    ----------
    colours : str or list of str, optional
        The palette to test. Defaults to the depictr qualitative palette. Needs
        at least two colours: a pairwise distance over fewer than two has no
        value, not an infinitely safe one. A single string is one colour, so
        it raises with that message.
    threshold : float
        The smallest acceptable colour difference, on the scale of ``metric``.
    metric : {"ciede2000", "cie76"}
        The colour difference to measure: CIEDE2000, the default, or the
        Euclidean distance in CIELAB that earlier versions measured.
        ``"cie76"`` is kept so that earlier results can be reproduced, and a
        later release will remove it.

    Returns
    -------
    dict
        ``min_delta_e`` (the worst case across conditions), ``by_condition``
        (the minimum colour difference for normal vision and each deficiency),
        ``worst_condition`` and ``worst_pair`` (the closest colours and where),
        ``safe`` (whether ``min_delta_e`` meets ``threshold``) and ``threshold``.

    See Also
    --------
    depictr.check_figure : the same question asked of a finished figure, which
        uses only as many colours as it has groups and has text and a background
        besides.

    References
    ----------
    Sharma, G., Wu, W., & Dalal, E. N. (2005). The CIEDE2000 color-difference
    formula: Implementation notes, supplementary test data, and mathematical
    observations. Color Research & Application, 30(1), 21-30.
    https://doi.org/10.1002/col.20070

    Examples
    --------
    >>> import depictr as dp
    >>> dp.palette_safety()['safe']
    True

    ggplot2's default palette of five colours, which passed under CIE76.

    >>> hue5 = ["#F8766D", "#A3A500", "#00BF7D", "#00B0F6", "#E76BF3"]
    >>> dp.palette_safety(hue5)['safe'], dp.palette_safety(hue5, metric="cie76")['safe']
    (False, True)
    """
    from .palette import depictr_palette

    _check_metric(metric)

    # `colours or ...` would have swapped in the default palette for an empty
    # list, reporting on eight colours the caller never passed.
    colours = depictr_palette() if colours is None else _as_colour_list(colours)
    if len(colours) < 2:
        # Otherwise the no-pair sentinel below survives to the result, which
        # then claims safe=True at an infinite distance and names one colour as
        # both halves of the worst pair. inf is also not JSON-serialisable, and
        # this dict gets printed straight into the published docs.
        raise ValueError(
            "`colours` needs at least two colours to have a pairwise distance."
        )
    by_condition, pairs = {}, {}
    by_condition["normal"], pairs["normal"] = _min_pairwise_delta_e(
        colours, metric=metric)
    for deficiency in DEFICIENCIES:
        sim = simulate_cvd(colours, deficiency, severity=1.0)
        by_condition[deficiency], pairs[deficiency] = _min_pairwise_delta_e(
            sim, metric=metric)
    worst_condition = min(by_condition, key=by_condition.get)
    min_delta_e = by_condition[worst_condition]
    i, j = pairs[worst_condition]
    return {
        "min_delta_e": round(min_delta_e, 2),
        "by_condition": {k: round(v, 2) for k, v in by_condition.items()},
        "worst_condition": worst_condition,
        "worst_pair": (colours[i], colours[j]),
        "safe": bool(min_delta_e >= threshold),
        "threshold": threshold,
    }

simulate_cvd

simulate_cvd(colours, deficiency, severity=1.0)

Simulate how a palette appears under a colour-vision deficiency.

Parameters:

Name Type Description Default
colours str or list of str

Colours (any matplotlib-readable form) to transform. A single string is one colour, and an empty list gives an empty list back, as in the R twin.

required
deficiency ('protan', 'deutan', 'tritan')

The deficiency to simulate (red-, green- or blue-weak vision).

"protan"
severity float

Severity in [0, 1]. Zero leaves the colours unchanged and one is the full deficiency. Machado et al. publish a matrix for each tenth of severity, which is used as it stands at 0.1, 0.2 and so on. A severity between two tenths takes the linear interpolation of their matrices, as the authors suggest and as colorspacious does.

1.0

Returns:

Type Description
list of str

The simulated colours as hex strings.

Examples:

>>> import depictr as dp
>>> dp.simulate_cvd(['#005b96', '#e69f00'], 'deutan')
['#275295', '#cab411']
See Also

depictr.simulate_figure : the same simulation applied to a whole figure.

Source code in src/depictr/cvd.py
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
def simulate_cvd(colours: str | list[str], deficiency: str,
                 severity: float = 1.0) -> list[str]:
    """Simulate how a palette appears under a colour-vision deficiency.

    Parameters
    ----------
    colours : str or list of str
        Colours (any matplotlib-readable form) to transform. A single string
        is one colour, and an empty list gives an empty list back, as in the
        R twin.
    deficiency : {"protan", "deutan", "tritan"}
        The deficiency to simulate (red-, green- or blue-weak vision).
    severity : float
        Severity in [0, 1]. Zero leaves the colours unchanged and one is the
        full deficiency. Machado et al. publish a matrix for each tenth of
        severity, which is used as it stands at 0.1, 0.2 and so on. A severity
        between two tenths takes the linear interpolation of their matrices,
        as the authors suggest and as colorspacious does.

    Returns
    -------
    list of str
        The simulated colours as hex strings.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.simulate_cvd(['#005b96', '#e69f00'], 'deutan')
    ['#275295', '#cab411']

    See Also
    --------
    depictr.simulate_figure : the same simulation applied to a whole figure.
    """
    _check_vision(deficiency, severity)
    colours = _as_colour_list(colours)
    if not colours:
        return []
    matrix = _machado_matrix(deficiency, severity)
    linear = _srgb_to_linear(_to_rgb01(colours))
    simulated = _linear_to_srgb(linear @ matrix.T)
    return [mcolors.to_hex(np.clip(row, 0, 1)) for row in simulated]

simulate_figure

simulate_figure(plot, deficiency, severity=1.0, dpi=150)

Show a finished figure as a reader with a colour-vision deficiency sees it.

:func:simulate_cvd simulates a list of colours, and this function the figure they end up in, legend, colourbar, text and background included. A copy of the plot is drawn and rasterised with matplotlib's Agg renderer. Every pixel then goes through the same transform: it is decoded from sRGB to linear light, multiplied by Machado et al.'s matrix for the deficiency and severity and encoded again. The alpha channel is kept as drawn.

The R twin's simulate_figure() applies the same matrices to the figure's vector elements and returns a gtable to draw or save. A solid region comes out in the same colour in both, to within the rounding of an eight-bit channel.

Parameters:

Name Type Description Default
plot ggplot or Compose

The plot, or a composition of several, as it would be saved. It is left as it was, since a copy is drawn. A plot whose draw() method has already been called is refused, because plotnine does not draw it correctly a second time.

required
deficiency ('protan', 'deutan', 'tritan')

The deficiency to simulate (red-, green- or blue-weak vision).

"protan"
severity float

Severity in [0, 1], as for :func:simulate_cvd. Zero returns the pixels as drawn, which is the figure under normal vision.

1.0
dpi float

The resolution to rasterise at, in dots per inch. The image is the figure's width and height in inches times dpi pixels.

150

Returns:

Type Description
ndarray

The image as floats in [0, 1], of shape (height, width, 4), with the channels red, green, blue and alpha. Matplotlib's imshow() and Streamlit's st.image() take it as it is.

Raises:

Type Description
TypeError

When plot is not a plotnine plot or composition.

ValueError

When deficiency, severity or dpi is out of range, or when plot has already been drawn.

Examples:

>>> import depictr as dp
>>> p = dp.ecdf_plot(dp.lexical_decision(), "RT", group="condition")
>>> seen = dp.simulate_figure(p, "deutan", dpi=72)
>>> seen.shape[2], bool(seen.min() >= 0 and seen.max() <= 1)
(4, True)

Under normal vision, for a side-by-side comparison:

>>> as_drawn = dp.simulate_figure(p, "deutan", severity=0, dpi=72)
>>> as_drawn.shape == seen.shape
True
Source code in src/depictr/cvd.py
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
def simulate_figure(plot, deficiency: str, severity: float = 1.0,
                    dpi: float = 150) -> np.ndarray:
    """Show a finished figure as a reader with a colour-vision deficiency sees it.

    :func:`simulate_cvd` simulates a list of colours, and this function the
    figure they end up in, legend, colourbar, text and background included. A
    copy of the plot is drawn and rasterised with matplotlib's Agg renderer.
    Every pixel then goes through the same transform: it is decoded from sRGB
    to linear light, multiplied by Machado et al.'s matrix for the deficiency
    and severity and encoded again. The alpha channel is kept as drawn.

    The R twin's ``simulate_figure()`` applies the same matrices to the
    figure's vector elements and returns a gtable to draw or save. A solid
    region comes out in the same colour in both, to within the rounding of an
    eight-bit channel.

    Parameters
    ----------
    plot : plotnine.ggplot or plotnine.composition.Compose
        The plot, or a composition of several, as it would be saved. It is
        left as it was, since a copy is drawn. A plot whose ``draw()`` method
        has already been called is refused, because plotnine does not draw it
        correctly a second time.
    deficiency : {"protan", "deutan", "tritan"}
        The deficiency to simulate (red-, green- or blue-weak vision).
    severity : float
        Severity in [0, 1], as for :func:`simulate_cvd`. Zero returns the
        pixels as drawn, which is the figure under normal vision.
    dpi : float
        The resolution to rasterise at, in dots per inch. The image is the
        figure's width and height in inches times ``dpi`` pixels.

    Returns
    -------
    numpy.ndarray
        The image as floats in [0, 1], of shape (height, width, 4), with the
        channels red, green, blue and alpha. Matplotlib's ``imshow()`` and
        Streamlit's ``st.image()`` take it as it is.

    Raises
    ------
    TypeError
        When ``plot`` is not a plotnine plot or composition.
    ValueError
        When ``deficiency``, ``severity`` or ``dpi`` is out of range, or when
        ``plot`` has already been drawn.

    Examples
    --------
    >>> import depictr as dp
    >>> p = dp.ecdf_plot(dp.lexical_decision(), "RT", group="condition")
    >>> seen = dp.simulate_figure(p, "deutan", dpi=72)
    >>> seen.shape[2], bool(seen.min() >= 0 and seen.max() <= 1)
    (4, True)

    Under normal vision, for a side-by-side comparison:

    >>> as_drawn = dp.simulate_figure(p, "deutan", severity=0, dpi=72)
    >>> as_drawn.shape == seen.shape
    True
    """
    import copy

    import matplotlib.pyplot as plt
    from matplotlib.backends.backend_agg import FigureCanvasAgg
    from plotnine import ggplot
    from plotnine.composition import Compose

    _check_vision(deficiency, severity)
    if not isinstance(plot, (ggplot, Compose)):
        raise TypeError("`plot` must be a plotnine ggplot or composition.")
    if (isinstance(dpi, bool) or not isinstance(dpi, numbers.Real)
            or not math.isfinite(dpi) or dpi <= 0):
        raise ValueError("`dpi` must be a positive number.")

    # Drawing builds a plot's layers and attaches the figure to it in place,
    # so a copy is drawn and the caller's plot is left as it was. A plot drawn
    # before cannot be drawn again: plotnine's copy shares its figure and its
    # built state, and the second drawing came out with the panel missing.
    if _drawn(plot):
        raise ValueError(
            "`plot` has already been drawn, and plotnine does not draw a plot "
            "correctly a second time. Pass the plot before it is drawn, or "
            "build it again.")
    figure = copy.deepcopy(plot).draw(show=False)
    try:
        figure.set_dpi(dpi)
        canvas = FigureCanvasAgg(figure)
        canvas.draw()
        rgba = np.asarray(canvas.buffer_rgba(), dtype=float) / 255
    finally:
        plt.close(figure)
    if severity == 0:
        return rgba
    return _simulate_pixels(rgba, deficiency, severity)

format_terms

format_terms(x, interaction='times', strip_prefix=True, tidy_intercept=True, wrap=None)

Tidy raw coefficient names for display.

Cleans up the term names that modelling functions produce so that they read well on a plot, following the rules of the R twin's format_terms(). The intercept is renamed Intercept, a b_ or bs_ prefix (as brms adds) is stripped from each component of an interaction, interaction colons are written with a chosen symbol, and underscores are shown as spaces, so word_frequency becomes word frequency.

The names patsy and formulaic give a formula's terms are read as well. A factor level becomes variable: level, as treatment[T.enhanced] and C(treatment)[T.enhanced] become treatment: enhanced, and a Q("...") wrapper is dropped, so Q("region")[T.North] becomes region: North. A colon inside a level, a call or a quoted name does not count as an interaction.

Parameters:

Name Type Description Default
x str or iterable of str

The term names. Missing values (None, NaN) are kept missing.

required
interaction ('times', 'asterisk', 'colon', 'space')

How to write an interaction: a multiplication sign (the default), an asterisk, the colon unchanged or a space.

"times"
strip_prefix bool

Whether to remove a leading b_ or bs_ from each interaction component.

True
tidy_intercept bool

Whether to replace (Intercept), Intercept and const with Intercept.

True
wrap int

Width at which to wrap long labels onto new lines, each line kept shorter than wrap characters, as R's strwrap() keeps them. The default leaves labels unwrapped.

None

Returns:

Type Description
str or list

A string for a string, otherwise a list as long as x, with None where x held a missing value.

Examples:

>>> import depictr as dp
>>> dp.format_terms(["Intercept", "treatment[T.enhanced]", "soil_ph"])
['Intercept', 'treatment: enhanced', 'soil ph']
>>> dp.format_terms("rainfall:treatment[T.enhanced]", interaction="asterisk")
'rainfall * treatment: enhanced'
>>> dp.format_terms('Q("region")[T.North]')
'region: North'
Source code in src/depictr/terms.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
def format_terms(x, interaction: str = "times", strip_prefix: bool = True,
                 tidy_intercept: bool = True, wrap: int | None = None):
    """Tidy raw coefficient names for display.

    Cleans up the term names that modelling functions produce so that they read
    well on a plot, following the rules of the R twin's ``format_terms()``. The
    intercept is renamed ``Intercept``, a ``b_`` or ``bs_`` prefix (as brms adds)
    is stripped from each component of an interaction, interaction colons are
    written with a chosen symbol, and underscores are shown as spaces, so
    ``word_frequency`` becomes ``word frequency``.

    The names patsy and formulaic give a formula's terms are read as well. A
    factor level becomes ``variable: level``, as ``treatment[T.enhanced]`` and
    ``C(treatment)[T.enhanced]`` become ``treatment: enhanced``, and a
    ``Q("...")`` wrapper is dropped, so ``Q("region")[T.North]`` becomes
    ``region: North``. A colon inside a level, a call or a quoted name does not
    count as an interaction.

    Parameters
    ----------
    x : str or iterable of str
        The term names. Missing values (``None``, ``NaN``) are kept missing.
    interaction : {"times", "asterisk", "colon", "space"}
        How to write an interaction: a multiplication sign (the default), an
        asterisk, the colon unchanged or a space.
    strip_prefix : bool
        Whether to remove a leading ``b_`` or ``bs_`` from each interaction
        component.
    tidy_intercept : bool
        Whether to replace ``(Intercept)``, ``Intercept`` and ``const`` with
        ``Intercept``.
    wrap : int, optional
        Width at which to wrap long labels onto new lines, each line kept
        shorter than ``wrap`` characters, as R's ``strwrap()`` keeps them. The
        default leaves labels unwrapped.

    Returns
    -------
    str or list
        A string for a string, otherwise a list as long as ``x``, with
        ``None`` where ``x`` held a missing value.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.format_terms(["Intercept", "treatment[T.enhanced]", "soil_ph"])
    ['Intercept', 'treatment: enhanced', 'soil ph']
    >>> dp.format_terms("rainfall:treatment[T.enhanced]", interaction="asterisk")
    'rainfall * treatment: enhanced'
    >>> dp.format_terms('Q("region")[T.North]')
    'region: North'
    """
    sep = interaction_separator(interaction)
    if wrap is not None and (isinstance(wrap, bool)
                             or not isinstance(wrap, numbers.Integral) or wrap < 1):
        raise ValueError("`wrap` must be a positive whole number or None.")

    def one(term):
        if _is_missing(term):
            return None
        return _format_one(str(term), sep, strip_prefix, tidy_intercept, wrap)

    if x is None or isinstance(x, str):
        return one(x)
    if not isinstance(x, Iterable):
        raise TypeError("`x` must be a string or an iterable of strings.")
    return [one(term) for term in x]

Composition and data

Compose several plots into one figure or save one to disk, and the datasets used throughout the documentation. These are the R package's own synthetic datasets, shipped as files, so an example gives the same numbers in both packages.

The datasets are used throughout the gallery, where every figure is drawn from one of them.

arrange_plots

arrange_plots(*plots, ncol=None, nrow=None, title=None, tag_levels='A', tag_size=None)

Arrange several plots into one composed figure.

Builds a grid by joining plots side by side (plotnine's |) into rows of ncol, then stacking the rows (/). The result is a plotnine composition, so it still has .draw and .save. This is the building block behind the multi-panel reports (for example :func:depictr.residual_diagnostics_plot).

Each panel is tagged A, B, C and so on by default, in reading order, as the R twin's patchwork annotation tags them. The tags run on through a composition passed as one of the plots, and a plot_spacer() takes none. A tag is drawn in the top-left corner of its panel at the theme's plot_tag size, 1.2 times the base size, as in ggplot2. In this twin each tag belongs to its panel's plot, so :func:depictr.check_figure measures it with the rest of the panel's text, where the R twin's audit leaves out the tags that patchwork draws around the panels.

Parameters:

Name Type Description Default
*plots ggplot or Compose

The plots to arrange. None entries are dropped.

()
ncol int

The grid shape. Give one and the other is derived from the count; give neither and a sensible default is chosen (one column for a single plot, two up to four plots, three beyond).

None
nrow int

The grid shape. Give one and the other is derived from the count; give neither and a sensible default is chosen (one column for a single plot, two up to four plots, three beyond).

None
title str

A title. Applied only when a single plot is passed: plotnine compositions cannot carry a figure-level title, so for a grid the title is dropped with a warning, and each panel should carry its own.

None
tag_levels ('A', 'a', '1', 'I', 'i')

How to tag the panels: capital or lower-case letters, Arabic numerals, or capital or lower-case Roman numerals, as patchwork's tag_levels takes them. A sequence of strings gives the tags themselves, one per panel in reading order. None leaves the panels untagged, as before this argument existed. Letters run to 26 panels.

"A"
tag_size float

The size of the tags in points, such as the 8 pt that Nature asks of panel labels. None, the default, leaves it to each panel's theme. Ignored when tag_levels is None.

None

Returns:

Type Description
ggplot or Compose

A single plot when one is passed, otherwise a composition. The plots passed in are left untagged.

Examples:

>>> import depictr as dp
>>> ld = dp.lexical_decision()
>>> left = dp.ecdf_plot(ld, "RT")
>>> right = dp.explore_distribution(ld, "RT")
>>> panel = dp.arrange_plots(left, right, ncol=2)

Lower-case tags at 8 pt, or none at all:

>>> panel = dp.arrange_plots(left, right, tag_levels="a", tag_size=8)
>>> panel = dp.arrange_plots(left, right, tag_levels=None)
Source code in src/depictr/compose.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def arrange_plots(*plots, ncol: int | None = None, nrow: int | None = None,
                  title: str | None = None, tag_levels="A",
                  tag_size: float | None = None):
    """Arrange several plots into one composed figure.

    Builds a grid by joining plots side by side (plotnine's ``|``) into rows of
    ``ncol``, then stacking the rows (``/``). The result is a plotnine
    composition, so it still has ``.draw`` and ``.save``. This is the building
    block behind the multi-panel reports (for example
    :func:`depictr.residual_diagnostics_plot`).

    Each panel is tagged A, B, C and so on by default, in reading order, as
    the R twin's patchwork annotation tags them. The tags run on through a
    composition passed as one of the plots, and a ``plot_spacer()`` takes
    none. A tag is drawn in the top-left corner of its panel at the theme's
    ``plot_tag`` size, 1.2 times the base size, as in ggplot2. In this twin
    each tag belongs to its panel's plot, so :func:`depictr.check_figure`
    measures it with the rest of the panel's text, where the R twin's audit
    leaves out the tags that patchwork draws around the panels.

    Parameters
    ----------
    *plots : plotnine.ggplot or plotnine.composition.Compose
        The plots to arrange. ``None`` entries are dropped.
    ncol, nrow : int, optional
        The grid shape. Give one and the other is derived from the count; give
        neither and a sensible default is chosen (one column for a single plot,
        two up to four plots, three beyond).
    title : str, optional
        A title. Applied only when a single plot is passed: plotnine
        compositions cannot carry a figure-level title, so for a grid the
        title is dropped with a warning, and each panel should carry its own.
    tag_levels : {"A", "a", "1", "I", "i"}, sequence of str or None
        How to tag the panels: capital or lower-case letters, Arabic numerals,
        or capital or lower-case Roman numerals, as patchwork's
        ``tag_levels`` takes them. A sequence of strings gives the tags
        themselves, one per panel in reading order. ``None`` leaves the panels
        untagged, as before this argument existed. Letters run to 26 panels.
    tag_size : float, optional
        The size of the tags in points, such as the 8 pt that Nature asks of
        panel labels. ``None``, the default, leaves it to each panel's theme.
        Ignored when ``tag_levels`` is None.

    Returns
    -------
    plotnine.ggplot or plotnine.composition.Compose
        A single plot when one is passed, otherwise a composition. The plots
        passed in are left untagged.

    Examples
    --------
    >>> import depictr as dp
    >>> ld = dp.lexical_decision()
    >>> left = dp.ecdf_plot(ld, "RT")
    >>> right = dp.explore_distribution(ld, "RT")
    >>> panel = dp.arrange_plots(left, right, ncol=2)

    Lower-case tags at 8 pt, or none at all:

    >>> panel = dp.arrange_plots(left, right, tag_levels="a", tag_size=8)
    >>> panel = dp.arrange_plots(left, right, tag_levels=None)
    """
    from plotnine import ggplot, ggtitle

    tag_style = _check_tag_levels(tag_levels)
    if tag_size is not None and (
            isinstance(tag_size, bool) or not isinstance(tag_size, numbers.Real)
            or not math.isfinite(tag_size) or tag_size <= 0):
        raise ValueError("`tag_size` must be a single positive number.")
    items = [p for p in plots if p is not None]
    if not items:
        raise ValueError("arrange_plots needs at least one plot.")
    if tag_style is not None:
        items = _tagged(items, tag_style, tag_size)
    n = len(items)
    if ncol is None and nrow is None:
        ncol = 1 if n == 1 else (2 if n <= 4 else 3)
    elif ncol is None:
        ncol = -(-n // nrow)  # ceil division
    rows = [reduce(lambda a, b: a | b, items[i:i + ncol])
            for i in range(0, n, ncol)]
    composed = reduce(lambda a, b: a / b, rows)
    # plotnine has no super-title for a composition; adding one would land on the
    # last panel. So only title a lone plot, and let grids self-title per panel.
    if title is not None:
        if isinstance(composed, ggplot):
            composed = composed + ggtitle(title)
        else:
            warnings.warn(
                "plotnine compositions cannot carry a figure-level title, so "
                "`title` is dropped for a multi-panel grid. Title the panels "
                "individually instead.",
                UserWarning,
                stacklevel=2,
            )
    return composed

save_plot

save_plot(plot, filename, width=7, height=4.5, dpi=300, units='in', check=False, **kwargs)

Save a depictr/plotnine plot at publication resolution.

Missing directories on the way to filename are created, and PDF, EPS and TIFF files are written to pass the technical checks journals run on submitted figures. With check=True, the figure is also audited with :func:depictr.check_figure at the width it is saved at.

A PDF or EPS embeds its fonts as TrueType (Type 42). matplotlib's default, Type 3, is the kind Nature refuses, and the setting holds for this save only. PostScript has no transparency, so matplotlib draws translucent layers opaque in an EPS, where they hide what lies beneath them, and a warning says how many layers that affects. A PDF keeps them as they are.

A TIFF is written as LZW-compressed RGB at dpi, with any transparent background composited onto white. matplotlib alone writes uncompressed RGBA, which PLOS refuses on both counts: the figure in the example below takes 10.8 MiB that way. Passing pil_kwargs leaves the TIFF to matplotlib as it was.

Which formats three publishers accepted on 2 October 2026:

Format PLOS Nature (final artwork) Elsevier
PDF No Yes, as vector art Yes
EPS Yes, with fonts embedded Yes, as vector art Yes, with fonts embedded
TIFF Yes, LZW-compressed RGB or greyscale, no alpha channel, at most 10 MB No Yes

Nature also asks for fonts embedded as TrueType 2 or 42, never Type 3, and for line art and text that are not rasterised. A figure with translucent layers therefore goes to Nature as a PDF. Elsevier asks for a graph sent as a TIFF at 1000 dpi, so pass dpi=1000 for one.

The R twin takes the file name first, as save_plot(filename, plot). For an EPS, it rasterises the translucent layers and whatever they overlap, where matplotlib draws them opaque.

PLOS ONE. Figures. https://journals.plos.org/plosone/s/figures

Nature. Guide to preparing final artwork. https://www.nature.com/documents/nature-final-artwork.pdf

Nature research figure guide. Preparing figures: our specifications. https://research-figure-guide.nature.com/figures/preparing-figures-our-specifications/

Elsevier. Artwork overview. https://www.elsevier.com/about/policies-and-standards/author/artwork-and-media-instructions/artwork-overview

Parameters:

Name Type Description Default
plot ggplot or Compose

The plot to save, or a composition of several.

required
filename str

Output path; the extension sets the format (.png, .pdf, .eps, .tiff, .svg).

required
width float

Figure size, in units. A composition is saved at this size too.

7
height float

Figure size, in units. A composition is saved at this size too.

7
dpi int

Dots per inch (300 suits print).

300
units str

Size units ("in", "cm" or "mm").

'in'
check bool

Whether to audit the figure with :func:depictr.check_figure at the width it is saved at, given as both width_cm and render_width_cm, so that the text sizes it reports are the printed ones. A warning names each check the figure fails. Defaults to False. The audit runs before the file is written, so a plot it cannot read is refused without saving. For the full report, or for other thresholds such as a journal's own text floor, call :func:depictr.check_figure with the saved width as both widths.

False
**kwargs

Passed to :meth:plotnine.ggplot.save, and from there to matplotlib's savefig().

{}

Returns:

Type Description
str

The filename written. With check=True, the R twin attaches the audit report to it, and this twin returns the filename alone.

Warns:

Type Description
UserWarning

When an EPS or PostScript file is saved from a plot with translucent layers, and, with check=True, when the figure fails a check.

Examples:

>>> import os, tempfile
>>> import depictr as dp
>>> ld = dp.lexical_decision()
>>> p = dp.ecdf_plot(ld, "RT")
>>> path = dp.save_plot(p, os.path.join(tempfile.mkdtemp(), "rt.png"),
...                     width=4, height=3, dpi=72)
>>> os.path.exists(path)
True

A TIFF well within the 10 MB that PLOS allows, in a folder that does not yet exist.

>>> tiff = os.path.join(tempfile.mkdtemp(), "figures", "rt.tiff")
>>> path = dp.save_plot(p, tiff)
>>> os.path.getsize(path) < 1_000_000
True

Audited at the 8.9 cm column it is saved for, where its text prints at the size it is drawn. It passes every check that applies, so nothing is said.

>>> column = os.path.join(tempfile.mkdtemp(), "rt-column.png")
>>> path = dp.save_plot(p, column, width=8.9, height=6, units="cm",
...                     dpi=72, check=True)
Source code in src/depictr/compose.py
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
def save_plot(plot, filename, width: float = 7, height: float = 4.5,
              dpi: int = 300, units: str = "in", check: bool = False,
              **kwargs) -> str:
    """Save a depictr/plotnine plot at publication resolution.

    Missing directories on the way to ``filename`` are created, and PDF, EPS
    and TIFF files are written to pass the technical checks journals run on
    submitted figures. With ``check=True``, the figure is also audited with
    :func:`depictr.check_figure` at the width it is saved at.

    A PDF or EPS embeds its fonts as TrueType (Type 42). matplotlib's default,
    Type 3, is the kind Nature refuses, and the setting holds for this save
    only. PostScript has no transparency, so matplotlib draws translucent
    layers opaque in an EPS, where they hide what lies beneath them, and a
    warning says how many layers that affects. A PDF keeps them as they are.

    A TIFF is written as LZW-compressed RGB at ``dpi``, with any transparent
    background composited onto white. matplotlib alone writes uncompressed
    RGBA, which PLOS refuses on both counts: the figure in the example below
    takes 10.8 MiB that way. Passing ``pil_kwargs`` leaves the TIFF to
    matplotlib as it was.

    Which formats three publishers accepted on 2 October 2026:

    | Format | PLOS | Nature (final artwork) | Elsevier |
    |:---|:---|:---|:---|
    | PDF | No | Yes, as vector art | Yes |
    | EPS | Yes, with fonts embedded | Yes, as vector art | Yes, with fonts embedded |
    | TIFF | Yes, LZW-compressed RGB or greyscale, no alpha channel, at most 10 MB | No | Yes |

    Nature also asks for fonts embedded as TrueType 2 or 42, never Type 3, and
    for line art and text that are not rasterised. A figure with translucent
    layers therefore goes to Nature as a PDF. Elsevier asks for a graph sent as
    a TIFF at 1000 dpi, so pass ``dpi=1000`` for one.

    The R twin takes the file name first, as ``save_plot(filename, plot)``. For
    an EPS, it rasterises the translucent layers and whatever they overlap,
    where matplotlib draws them opaque.

    PLOS ONE. Figures. https://journals.plos.org/plosone/s/figures

    Nature. Guide to preparing final artwork.
    https://www.nature.com/documents/nature-final-artwork.pdf

    Nature research figure guide. Preparing figures: our specifications.
    https://research-figure-guide.nature.com/figures/preparing-figures-our-specifications/

    Elsevier. Artwork overview.
    https://www.elsevier.com/about/policies-and-standards/author/artwork-and-media-instructions/artwork-overview

    Parameters
    ----------
    plot : plotnine.ggplot or plotnine.composition.Compose
        The plot to save, or a composition of several.
    filename : str
        Output path; the extension sets the format (``.png``, ``.pdf``,
        ``.eps``, ``.tiff``, ``.svg``).
    width, height : float
        Figure size, in ``units``. A composition is saved at this size too.
    dpi : int
        Dots per inch (300 suits print).
    units : str
        Size units (``"in"``, ``"cm"`` or ``"mm"``).
    check : bool
        Whether to audit the figure with :func:`depictr.check_figure` at the
        width it is saved at, given as both ``width_cm`` and
        ``render_width_cm``, so that the text sizes it reports are the printed
        ones. A warning names each check the figure fails. Defaults to False.
        The audit runs before the file is written, so a plot it cannot read is
        refused without saving. For the full report, or for other thresholds
        such as a journal's own text floor, call :func:`depictr.check_figure`
        with the saved width as both widths.
    **kwargs
        Passed to :meth:`plotnine.ggplot.save`, and from there to matplotlib's
        ``savefig()``.

    Returns
    -------
    str
        The filename written. With ``check=True``, the R twin attaches the
        audit report to it, and this twin returns the filename alone.

    Warns
    -----
    UserWarning
        When an EPS or PostScript file is saved from a plot with translucent
        layers, and, with ``check=True``, when the figure fails a check.

    Examples
    --------
    >>> import os, tempfile
    >>> import depictr as dp
    >>> ld = dp.lexical_decision()
    >>> p = dp.ecdf_plot(ld, "RT")
    >>> path = dp.save_plot(p, os.path.join(tempfile.mkdtemp(), "rt.png"),
    ...                     width=4, height=3, dpi=72)
    >>> os.path.exists(path)
    True

    A TIFF well within the 10 MB that PLOS allows, in a folder that does not
    yet exist.

    >>> tiff = os.path.join(tempfile.mkdtemp(), "figures", "rt.tiff")
    >>> path = dp.save_plot(p, tiff)
    >>> os.path.getsize(path) < 1_000_000
    True

    Audited at the 8.9 cm column it is saved for, where its text prints at the
    size it is drawn. It passes every check that applies, so nothing is said.

    >>> column = os.path.join(tempfile.mkdtemp(), "rt-column.png")
    >>> path = dp.save_plot(p, column, width=8.9, height=6, units="cm",
    ...                     dpi=72, check=True)
    """
    import matplotlib as mpl
    from plotnine import theme
    from plotnine.composition import Compose

    if not isinstance(check, bool):
        raise TypeError("`check` must be True or False.")
    # The audit runs before anything is written, so a figure it cannot read,
    # or a width it cannot convert, leaves no file behind.
    report = saved_cm = None
    if check:
        from .audit import check_figure

        saved_cm = _saved_width_cm(width, units)
        report = check_figure(plot, width_cm=saved_cm,
                              render_width_cm=saved_cm)

    # plotnine draws a composition at the figure size of its last plot's theme
    # and ignores the width and height its save() is given, so the size is
    # written into that theme. Adding to a composition copies it, which leaves
    # the caller's untouched.
    if isinstance(plot, Compose) and width is not None and height is not None:
        plot = plot + theme(figure_size=(_inches(width, units),
                                         _inches(height, units)))

    # plotnine joins `path` and the file name itself. The joined path is needed
    # here first, for the directory and for a TIFF written outside matplotlib.
    path = kwargs.pop("path", None)
    target = filename
    if path and isinstance(filename, (str, os.PathLike)):
        target = os.path.join(path, filename)
    fmt = _save_format(target, kwargs.get("format"))
    if isinstance(target, (str, os.PathLike)):
        os.makedirs(os.path.dirname(os.fspath(target)) or ".", exist_ok=True)

    with mpl.rc_context(_EMBEDDED_FONTS):
        if fmt in ("tif", "tiff") and "pil_kwargs" not in kwargs:
            _save_tiff(plot, target, width, height, dpi, units, kwargs)
        else:
            _with_embedded_fonts(plot).save(
                target, width=width, height=height, dpi=dpi, units=units,
                verbose=False, **kwargs)

    if fmt in ("eps", "ps"):
        count = _translucent_layers(plot)
        if count:
            kind = "EPS" if fmt == "eps" else "PostScript"
            layers = "layer" if count == 1 else "layers"
            warnings.warn(
                f"{kind} has no transparency, so matplotlib draws {count} "
                f"translucent {layers} opaque. Save as PDF, or as TIFF at "
                "300-600 dpi, to keep them.",
                UserWarning,
                stacklevel=2,
            )
    if report is not None:
        failed = _failed_checks(report)
        if failed:
            noun = "check" if len(failed) == 1 else "checks"
            warnings.warn(
                f"The figure fails {len(failed)} {noun} at the "
                f"{saved_cm:.2f} cm it is saved at: {_and_list(failed)}. "
                "Pass that width to check_figure() as both width_cm and "
                "render_width_cm for the full report.",
                UserWarning,
                stacklevel=2,
            )
    return filename

crop_yield

crop_yield(seed=None)

A field trial in which the fertiliser effect depends on the treatment.

200 fields: field (identifier), treatment (standard, enhanced), rainfall (mm), fertiliser (kg/ha), soil_ph and yield (t/ha). The R package's crop_yield.

Parameters:

Name Type Description Default
seed None

Deprecated and ignored. Passing a value raises a FutureWarning.

None

Examples:

>>> import depictr as dp
>>> dp.crop_yield().shape
(200, 6)
Source code in src/depictr/data.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def crop_yield(seed: int | None = None) -> pd.DataFrame:
    """A field trial in which the fertiliser effect depends on the treatment.

    200 fields: ``field`` (identifier), ``treatment`` (standard, enhanced),
    ``rainfall`` (mm), ``fertiliser`` (kg/ha), ``soil_ph`` and ``yield``
    (t/ha). The R package's ``crop_yield``.

    Parameters
    ----------
    seed : None
        Deprecated and ignored. Passing a value raises a ``FutureWarning``.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.crop_yield().shape
    (200, 6)
    """
    return _load("crop_yield", seed)

wellbeing_survey

wellbeing_survey(seed=None)

A cross-sectional survey with regional contrasts and informative missingness.

300 respondents: id, region (East, North, South, West), age, education (ordered: secondary < undergraduate < postgraduate), income, stress, sleep_hours, exercise_days and life_satisfaction, with values missing in income, sleep hours, exercise days and life satisfaction. The R package's wellbeing_survey.

Parameters:

Name Type Description Default
seed None

Deprecated and ignored. Passing a value raises a FutureWarning.

None

Examples:

>>> import depictr as dp
>>> dp.wellbeing_survey().shape
(300, 9)
Source code in src/depictr/data.py
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def wellbeing_survey(seed: int | None = None) -> pd.DataFrame:
    """A cross-sectional survey with regional contrasts and informative missingness.

    300 respondents: ``id``, ``region`` (East, North, South, West), ``age``,
    ``education`` (ordered: secondary < undergraduate < postgraduate),
    ``income``, ``stress``, ``sleep_hours``, ``exercise_days`` and
    ``life_satisfaction``, with values missing in income, sleep hours, exercise
    days and life satisfaction. The R package's ``wellbeing_survey``.

    Parameters
    ----------
    seed : None
        Deprecated and ignored. Passing a value raises a ``FutureWarning``.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.wellbeing_survey().shape
    (300, 9)
    """
    return _load("wellbeing_survey", seed)

lexical_decision

lexical_decision(seed=None)

A priming experiment with crossed participants and items.

960 trials: 24 participant levels crossed with 40 item levels, condition (related, unrelated), modality (visual, auditory), word_frequency, RT (ms) and accuracy (0/1). The R package's lexical_decision.

Parameters:

Name Type Description Default
seed None

Deprecated and ignored. Passing a value raises a FutureWarning.

None

Examples:

>>> import depictr as dp
>>> dp.lexical_decision().shape
(960, 7)
Source code in src/depictr/data.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
def lexical_decision(seed: int | None = None) -> pd.DataFrame:
    """A priming experiment with crossed participants and items.

    960 trials: 24 ``participant`` levels crossed with 40 ``item`` levels,
    ``condition`` (related, unrelated), ``modality`` (visual, auditory),
    ``word_frequency``, ``RT`` (ms) and ``accuracy`` (0/1). The R package's
    ``lexical_decision``.

    Parameters
    ----------
    seed : None
        Deprecated and ignored. Passing a value raises a ``FutureWarning``.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.lexical_decision().shape
    (960, 7)
    """
    return _load("lexical_decision", seed)

clinical_trial

clinical_trial(seed=None)

A two-arm trial with separating survival curves and a rare adverse event.

300 patients: patient (identifier), arm (placebo, treatment), age, biomarker, follow-up time (months), event (1 = event, 0 = censored) and adverse_event (0/1). The R package's clinical_trial.

Parameters:

Name Type Description Default
seed None

Deprecated and ignored. Passing a value raises a FutureWarning.

None

Examples:

>>> import depictr as dp
>>> dp.clinical_trial().shape
(300, 7)
Source code in src/depictr/data.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def clinical_trial(seed: int | None = None) -> pd.DataFrame:
    """A two-arm trial with separating survival curves and a rare adverse event.

    300 patients: ``patient`` (identifier), ``arm`` (placebo, treatment),
    ``age``, ``biomarker``, follow-up ``time`` (months), ``event`` (1 = event,
    0 = censored) and ``adverse_event`` (0/1). The R package's
    ``clinical_trial``.

    Parameters
    ----------
    seed : None
        Deprecated and ignored. Passing a value raises a ``FutureWarning``.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.clinical_trial().shape
    (300, 7)
    """
    return _load("clinical_trial", seed)

monthly_sales

monthly_sales(seed=None)

Two monthly sales series with a trend and a yearly season.

144 rows in long format: date (the first of each month, 2018 to 2023), series (indoor, outdoor) and sales. The R package's monthly_sales. It is not in :data:DATASETS, which lists the cross-sectional datasets the web app offers.

Parameters:

Name Type Description Default
seed None

Deprecated and ignored. Passing a value raises a FutureWarning.

None

Examples:

>>> import depictr as dp
>>> dp.monthly_sales().shape
(144, 3)
Source code in src/depictr/data.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
def monthly_sales(seed: int | None = None) -> pd.DataFrame:
    """Two monthly sales series with a trend and a yearly season.

    144 rows in long format: ``date`` (the first of each month, 2018 to 2023),
    ``series`` (indoor, outdoor) and ``sales``. The R package's
    ``monthly_sales``. It is not in :data:`DATASETS`, which lists the
    cross-sectional datasets the web app offers.

    Parameters
    ----------
    seed : None
        Deprecated and ignored. Passing a value raises a ``FutureWarning``.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.monthly_sales().shape
    (144, 3)
    """
    return _load("monthly_sales", seed)

DATASETS module-attribute

DATASETS = {'crop_yield': crop_yield, 'wellbeing_survey': wellbeing_survey, 'lexical_decision': lexical_decision, 'clinical_trial': clinical_trial}