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 all 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 and the figure audit, 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=0.6, 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.

required
group str

A grouping column mapped to colour/fill.

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

What to draw.

"density"
bins int

Number of histogram bins.

30
alpha float

Fill transparency, useful when groups overlap.

0.6
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
 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
def explore_distribution(data, x, group=None, kind="density", bins=30,
                         alpha=0.6, 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.
    group : str, optional
        A grouping column mapped to colour/fill.
    kind : {"density", "histogram", "both"}
        What to draw.
    bins : int
        Number of histogram bins.
    alpha : float
        Fill transparency, useful when groups overlap.
    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`.")
    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) 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 0.2)
        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 + scale_fill_depictr() + scale_colour_depictr()
        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.

required
group str

A second categorical column mapped to fill, drawn side by side.

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
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_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.
    group : str, optional
        A second categorical column mapped to fill, drawn side by side.
    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 group:
        counts = (data.groupby([group, x], observed=True).size()
                  .rename("n").reset_index())
        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")
             + scale_fill_depictr())
        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.

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
 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 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.

    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`.")

    x_num = is_numeric_dtype(data[x])
    y_num = is_numeric_dtype(data[y])

    if x_num and y_num:
        return scatter_trend(data, x, y, title=title)

    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=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 confidence band.

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 via :func:depictr.theme.scale_colour_depictr.

None
method str

Smoothing method passed to :func:plotnine.geom_smooth, for example "lm" for a straight line or "lowess" for a local fit.

'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
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
def scatter_trend(data, x, y, group=None, method="lm", title=None):
    """Scatter plot with a fitted trend line and confidence band.

    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 via :func:`depictr.theme.scale_colour_depictr`.
    method : str
        Smoothing method passed to :func:`plotnine.geom_smooth`, for example
        ``"lm"`` for a straight line or ``"lowess"`` for a local fit.
    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:
        if group not in data.columns:
            raise KeyError(f"{group!r} is not a column of `data`.")
        p = (
            ggplot(data, aes(x=x, y=y, color=group, fill=group))
            + geom_point(alpha=0.6)
            + geom_smooth(method=method, alpha=0.2)
            + scale_colour_depictr()
            + scale_fill_depictr()
        )
    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, title=None)

Heatmap of pairwise Pearson correlations among numeric columns.

Parameters:

Name Type Description Default
data DataFrame
required
cols list of str

Columns to include; defaults to all numeric columns.

None
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)
Source code in src/depictr/eda.py
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
def correlation_heatmap(data, cols=None, title=None):
    """Heatmap of pairwise Pearson correlations among numeric columns.

    Parameters
    ----------
    data : pandas.DataFrame
    cols : list of str, optional
        Columns to include; defaults to all numeric columns.
    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)
    """
    num = data[cols] if cols else data.select_dtypes("number")
    # 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)
    corr = num.corr()
    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)
    # 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:.2f}")
    return (
        ggplot(long, aes(x="var1", y="var2", fill="r"))
        + geom_tile(color="white")
        + geom_text(aes(label="label"), size=8, color="#1a1a1a")
        + scale_fill_gradientn(
            colors=depictr_palette(7, kind="diverging"), limits=(-1, 1),
            name="Correlation",
        )
        + 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
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
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.

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
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
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.
    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:
        p = (ggplot(data, aes(x=x, color=group))
             + stat_ecdf(size=0.9)
             + scale_colour_depictr())
    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.

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.

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
 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
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.

    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.
    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`.")

    df = data[[x, group]].dropna().copy()
    # 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))
        + geom_area(stat="density", alpha=0.8, color="white", size=0.3)
        + facet_grid(f"{group} ~ .", scales="free_y")
        + scale_fill_depictr()
        # 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.

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
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
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.
    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.")

    levels = list(pd.unique(data[group].dropna()))
    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)

    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
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
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)

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.

required
title str

Plot title.

None

Returns:

Type Description
ggplot
Notes

The interval is a normal-approximation 95% confidence interval for the mean (mean plus or minus 1.96 standard errors). It describes the precision of the mean, not the spread of the data.

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
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
def group_comparison_plot(data, x, group, title=None):
    """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.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    Notes
    -----
    The interval is a normal-approximation 95% confidence interval for the mean
    (mean plus or minus 1.96 standard errors). It describes the precision of the
    mean, not the spread of the data.

    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`.")

    df = data[[x, group]].dropna()
    stats = (df.groupby(group, observed=True)[x]
             .agg(["mean", "std", "count"]).reset_index())
    se = stats["std"] / np.sqrt(stats["count"])
    stats["lower"] = stats["mean"] - 1.96 * se
    stats["upper"] = stats["mean"] + 1.96 * se

    return (
        ggplot(df, aes(x=group, color=group))
        + geom_jitter(aes(y=x), width=0.12, height=0, alpha=0.25, size=1.5)
        + geom_errorbar(stats, aes(ymin="lower", ymax="upper"),
                        width=0.15, size=0.9)
        + geom_point(stats, aes(y="mean"), size=3.5)
        + scale_colour_depictr()
        + 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.

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
 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
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.
    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:
        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 + scale_fill_depictr() + scale_colour_depictr()
    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
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
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)

    return arrange_plots(*panels, ncol=n, title=title)

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 in the direction of its loading, labelled with the variable name. 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

Examples:

>>> import depictr as dp
>>> wb = dp.wellbeing_survey()
>>> p = dp.pca_plot(wb, group="region")
Source code in src/depictr/multivariate.py
 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
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 in the direction of its loading, labelled with the variable name. 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

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

    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()

    # Loadings, scaled to the span of the scores so the arrows sit among the
    # points rather than near the origin (a standard biplot rescaling).
    loadings = pca.components_.T  # variables x components
    span = np.abs(scores).max(axis=0)
    arrow_len = np.abs(loadings).max(axis=0)
    scale = span / arrow_len * 0.9
    load = pd.DataFrame({
        "x": 0.0, "y": 0.0,
        "xend": loadings[:, 0] * scale[0],
        "yend": loadings[:, 1] * scale[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=ACCENT, size=0.7,
                       arrow=_arrow())
        + geom_text(aes(x="xend", y="yend", label="label"), data=load,
                    color=ACCENT, size=9, fontweight="bold",
                    nudge_y=span[1] * 0.04)
        + 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
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
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)

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

The data are reduced to two principal components for display, k-means is run in that two-dimensional space, and points are coloured by cluster with each cluster centroid marked by a larger outlined point.

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.cluster_plot(wb, k=3)
Source code in src/depictr/multivariate.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
def cluster_plot(data, cols=None, k=3, title=None):
    """k-means clusters drawn on the first two principal components.

    The data are reduced to two principal components for display, k-means is run
    in that two-dimensional space, and points are coloured by cluster with each
    cluster centroid marked by a larger outlined point.

    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.cluster_plot(wb, k=3)
    """
    _require_sklearn()
    from sklearn.cluster import KMeans
    from sklearn.decomposition import PCA

    num = _numeric_frame(data, cols)
    X = _standardise(num)
    scores = PCA(n_components=2).fit_transform(X)
    km = KMeans(n_clusters=k, n_init=10, random_state=0).fit(scores)

    pts = pd.DataFrame({
        "PC1": scores[:, 0], "PC2": scores[:, 1],
        "cluster": pd.Categorical(km.labels_ + 1),
    })
    cent = pd.DataFrame(km.cluster_centers_, columns=["PC1", "PC2"])
    cent["cluster"] = pd.Categorical(np.arange(1, k + 1))

    return (
        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=k, name="Cluster")
        + labs(x="PC1", y="PC2", title=title)
        + theme_depictr()
    )

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").mean(numeric_only=True))
Source code in src/depictr/multivariate.py
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
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").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
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
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')

Kaplan-Meier survival curves, optionally by group, with a log-rank test.

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 (1 = event, 0 = censored).

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

Accepted for future use; the Python twin does not yet draw the confidence band or censor marks the R package draws.

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'

Returns:

Type Description
ggplot

The plot carries .at_risk (a DataFrame of number-at-risk counts) and, when grouped, .logrank_p and .logrank_stat.

Examples:

>>> import depictr as dp
>>> ct = dp.clinical_trial()
>>> p = dp.survival_plot(ct["time"], ct["event"], group=ct["arm"])
Source code in src/depictr/survival.py
 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
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"):
    """Kaplan-Meier survival curves, optionally by group, with a log-rank test.

    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 (1 = event, 0 = censored).
    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
        Accepted for future use; the Python twin does not yet draw the
        confidence band or censor marks the R package draws.
    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.

    Returns
    -------
    plotnine.ggplot
        The plot carries ``.at_risk`` (a DataFrame of number-at-risk counts) and,
        when grouped, ``.logrank_p`` and ``.logrank_stat``.

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

    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 = np.asarray(event, dtype=int)
    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.")
    # 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))

    curves, 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))
        sf = kmf.survival_function_.reset_index()
        sf.columns = ["time", "surv"]
        sf["group"] = str(lvl)
        curves.append(sf)
        for b in breaks:
            at_risk_rows.append({"group": str(lvl), "time": round(float(b), 1),
                                 "n_at_risk": int(np.sum(time[mask] >= b))})
    curve = pd.concat(curves, ignore_index=True)
    curve["group"] = pd.Categorical(curve["group"],
                                    categories=[str(lvl) for lvl in levels],
                                    ordered=True)

    multi = len(levels) > 1
    if multi:
        mapping = aes("time", "surv", color="group")
        p = ggplot(curve, mapping) + geom_step(size=0.9) + scale_colour_depictr()
    else:
        from .palette import BRAND
        p = ggplot(curve, aes("time", "surv")) + geom_step(size=0.9, color=BRAND)

    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
        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.
    order = [str(lvl) for lvl in levels]
    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)
    breaks = [b for b in np.unique(at_risk_df["time"]) if b >= 0]
    # 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)
        + 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.

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.

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
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
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.

    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.
    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)
        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.

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
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
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.
    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 subseries plot: value by position in the period, one line per cycle.

Each cycle (for example each year of monthly data) is one line, drawn across the within-period position (month 1 to 12); a trailing incomplete cycle is drawn as the short line it is. Overlaying the cycles makes the repeating shape and any drift between cycles easy to read. The lines take a sequential light-to-dark ramp so they read in time order.

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.

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
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
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
def seasonal_plot(x, period=None, title=None):
    """Seasonal subseries plot: value by position in the period, one line per cycle.

    Each cycle (for example each year of monthly data) is one line, drawn across
    the within-period position (month 1 to 12); a trailing incomplete cycle is
    drawn as the short line it is. Overlaying the cycles makes the repeating
    shape and any drift between cycles easy to read. The lines take a sequential
    light-to-dark ramp so they read in time order.

    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.
    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).
    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, period)

    values = series.to_numpy()
    n = len(values)
    if n == 0:
        raise ValueError("`x` has no non-missing values to plot.")
    position = (np.arange(n) % period) + 1
    cycle = np.arange(n) // period
    n_cycles = int(cycle.max()) + 1
    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(n_cycles)],
                                ordered=True),
    })

    return (
        ggplot(df, aes("position", "value", color="cycle", group="cycle"))
        + geom_line(size=0.7)
        + geom_point(size=1.5)
        + scale_x_continuous(breaks=list(range(1, period + 1)))
        # The cycles are a sequence, not unrelated categories, so they take a
        # sequential (light-to-dark) ramp rather than the qualitative palette:
        # it reads in time order and, unlike the eight Okabe-Ito colours, it
        # stays legible however many cycles the series holds. 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=depictr_palette(n_cycles, kind="sequential"),
                             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.

required
rolling int

Window length for a centred rolling mean, drawn over the series in the accent colour. Omit for the raw line only.

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.timeseries_plot(series, rolling=12)
Source code in src/depictr/timeseries.py
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
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.
    rolling : int, optional
        Window length for a centred rolling mean, drawn over the series in the
        accent colour. Omit for the raw line only.
    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.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()})

    p = ggplot(df, aes("x", "value")) + geom_line(color=BRAND, size=0.7)
    if rolling:
        df = df.assign(roll=df["value"].rolling(int(rolling), center=True).mean())
        p = p + geom_line(aes(y="roll"), data=df, color=ACCENT, size=1.0)
    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=2000, effsize='hedges_g', two_panel=False, title=None, seed=None)

Cumming-style 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. A dashed line marks a difference of zero (the reference), and the standardised effect size is annotated beside each point. With two_panel=True this difference axis sits beneath a panel of the raw data and group means (the Gardner-Altman layout).

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 group level.

None
conf_level float

Confidence level for the bootstrap difference intervals.

0.95
n_boot int

Number of bootstrap resamples for each difference interval.

2000
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 difference axis beneath a panel of the raw data and group means (the Gardner-Altman layout), returning a composition.

False
title str

Plot title.

None
seed int

Seed for the bootstrap, for reproducible intervals.

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
 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
def estimation_plot(data, y, group, reference=None, conf_level=0.95,
                    n_boot=2000, effsize="hedges_g", two_panel=False,
                    title=None, seed=None):
    """Cumming-style 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. A dashed line marks a
    difference of zero (the reference), and the standardised effect size is
    annotated beside each point. With ``two_panel=True`` this difference axis sits
    beneath a panel of the raw data and group means (the Gardner-Altman layout).

    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 group level.
    conf_level : float
        Confidence level for the bootstrap difference intervals.
    n_boot : int
        Number of bootstrap resamples for each difference interval.
    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 difference axis beneath a panel of the raw data
        and group means (the Gardner-Altman layout), returning a composition.
    title : str, optional
        Plot title.
    seed : int, optional
        Seed for the bootstrap, for reproducible intervals.

    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'.")
    if n_boot < 1:
        raise ValueError("`n_boot` must be a positive integer.")

    d = data[[y, group]].dropna()
    levels = list(pd.unique(d[group]))
    if len(levels) < 2:
        raise ValueError("`group` must have at least two non-empty levels.")

    ref = reference if reference is not None else levels[0]
    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]

    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)
    # Keep the non-reference groups in their original order up the axis.
    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
         # Pad an invisible slot for the reference so the axis reads naturally.
         + scale_x_discrete(limits=others)
         + expand_limits(x=levels)
         + labs(x="", y=f"Mean difference\n(vs. {ref})",
                title=None if two_panel else title)
         + 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, above the aligned difference axis.
    from scipy import stats

    summ = []
    for g in levels:
        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=levels, ordered=True)

    top = (
        ggplot(summ_df, aes(x="group", y="mean"))
        + geom_jitter(aes(x=group, y=y), data=d, width=0.12, alpha=0.25,
                      color=brand, size=0.9, inherit_aes=False)
        + 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)
        # 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"))
    )
    composed = arrange_plots(top, p, ncol=1)
    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.

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 non-numeric column whose distinct-value count is at least max_levels and exceeds half the number of rows is treated as an identifier and skipped, so it does not explode into one row per value.

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
 15
 16
 17
 18
 19
 20
 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
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
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``.

    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 non-numeric column whose distinct-value
        count is at least ``max_levels`` and exceeds half the number of rows is
        treated as an identifier and skipped, so it does not explode into one row
        per value.

    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 non-numeric column with nearly as many
        # distinct values as rows would expand into hundreds of useless rows.
        kept = []
        for v in vars:
            col = data[v]
            if not pd.api.types.is_numeric_dtype(col):
                n_lev = col.dropna().nunique()
                if n_lev >= max_levels and n_lev > n / 2:
                    continue
            kept.append(v)
        vars = kept

    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 pd.api.types.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)

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

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', 'ascending', 'descending')

Order of the terms up the axis.

"model"
conf_level float

Confidence level passed to :func:tidy_estimates.

0.95
title str
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.coefficient_plot(model, title="Drivers of crop yield")
Source code in src/depictr/models.py
 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
def coefficient_plot(model, intercept: bool = False, order: str = "model",
                     conf_level: float = 0.95, title=None):
    """Forest (dot-and-whisker) plot of model coefficients.

    Parameters
    ----------
    model : statsmodels results object or pandas.DataFrame
        See :func:`tidy_estimates`.
    intercept : bool
        Whether to include the intercept term.
    order : {"model", "ascending", "descending"}
        Order of the terms up the axis.
    conf_level : float
        Confidence level passed to :func:`tidy_estimates`.
    title : str, optional

    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.coefficient_plot(model, title="Drivers of crop yield")
    """
    est = tidy_estimates(model, conf_level=conf_level)
    if not intercept:
        est = est[~est["term"].str.fullmatch(r"(?i)\(?intercept\)?|const")]
    if order == "ascending":
        est = est.sort_values("estimate")
    elif order == "descending":
        est = est.sort_values("estimate", ascending=False)
    # Reverse so the first term reads at the top.
    levels = list(est["term"])[::-1]
    est = est.assign(term=pd.Categorical(est["term"], categories=levels, ordered=True))
    return (
        ggplot(est, aes(x="estimate", y="term"))
        + geom_vline(xintercept=0, linetype="dashed", color="#9e9e9e")
        + 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="Estimate", y="", title=title)
        + theme_depictr()
    )

tidy_estimates

tidy_estimates(model, conf_level=0.95)

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 result), or a data frame already carrying the estimates.

required
conf_level float

Confidence level for the interval when reading it from a model.

0.95

Returns:

Type Description
DataFrame

Columns term, estimate, conf_low, conf_high.

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
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
def tidy_estimates(model, conf_level: float = 0.95) -> 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`` result), or a data frame already carrying the
        estimates.
    conf_level : float
        Confidence level for the interval when reading it from a model.

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

    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']
    """
    if isinstance(model, pd.DataFrame):
        return _tidy_from_frame(model)
    if hasattr(model, "params") and hasattr(model, "conf_int"):
        ci = model.conf_int(alpha=1 - conf_level)
        ci = pd.DataFrame(ci)
        ci.columns = ["conf_low", "conf_high"][: ci.shape[1]]
        out = pd.DataFrame({
            "term": list(model.params.index),
            "estimate": model.params.to_numpy(),
            "conf_low": ci.iloc[:, 0].to_numpy(),
            "conf_high": ci.iloc[:, 1].to_numpy(),
        })
        return out
    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 reference (most common) level. The line is the predicted response and the ribbon its confidence band, both read from statsmodels' get_prediction.

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. Must be a numeric column.

required
conf_level float

Confidence level for the band.

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
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
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 reference
    (most common) level. The line is the predicted response and the ribbon its
    confidence band, both read from statsmodels' ``get_prediction``.

    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. Must be a numeric column.
    conf_level : float
        Confidence level for the band.
    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.")

    frame = _model_frame(model)
    if var not in frame.columns:
        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."
        )

    predictors = _predictor_columns(model, frame)
    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=f"Predicted {_response_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 (numeric) or reference level (categorical).

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.

required
conf_level float

Confidence level for the bands.

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
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 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 (numeric) or
    reference level (categorical).

    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.
    conf_level : float
        Confidence level for the bands.
    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.")

    frame = _model_frame(model)
    for name in (x, group):
        if name not in frame.columns:
            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.")

    predictors = _predictor_columns(model, frame)
    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=f"Predicted {_response_label(model)}",
               color=group, fill=group, title=title)
        + theme_depictr()
    )

compare_models

compare_models(models, intercept=False, conf_level=0.95, title=None)

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.

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.

0.95
title str

Plot title.

None

Returns:

Type Description
ggplot

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
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
def compare_models(models, intercept: bool = False, conf_level: float = 0.95,
                   title=None):
    """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.

    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`.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    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 tidy_estimates

    if not isinstance(models, dict) or not models:
        raise ValueError("`models` must be a non-empty {name: model} dict.")

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

    if not intercept:
        est = est[~est["term"].str.fullmatch(r"(?i)\(?intercept\)?|const")]
    if est.empty:
        raise ValueError("Nothing left to plot after dropping the intercept.")

    # 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["term"]))
    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["term"], est["model"], strict=True)]

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

    return (
        ggplot(est, aes(x="estimate", y="y", color="model"))
        + geom_vline(xintercept=0, linetype="dashed", color="#9e9e9e")
        + 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="Estimate", y="", color="Model", title=title)
        + theme_depictr()
        + theme(panel_grid_major_y=element_blank())
    )

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
>>> wb = dp.wellbeing_survey().dropna(subset=["life_satisfaction", "sleep_hours"])
>>> mm = smf.mixedlm("life_satisfaction ~ stress + sleep_hours", wb,
...                  groups=wb["region"]).fit()
>>> p = dp.random_effects_plot(mm)
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
148
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
    >>> wb = dp.wellbeing_survey().dropna(subset=["life_satisfaction", "sleep_hours"])
    >>> mm = smf.mixedlm("life_satisfaction ~ stress + sleep_hours", wb,
    ...                  groups=wb["region"]).fit()
    >>> p = dp.random_effects_plot(mm)
    """
    _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%. 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
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
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%. 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, accent 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
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
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, accent 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)

    # First shared term at the top once flipped; sources keyed to brand vs accent.
    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='n', power='power', group=None, title=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 0.8 (the conventional target). Where the curve crosses the line is the sample size that reaches adequate power. Pass a group to overlay one coloured curve per condition (for example several effect sizes or designs).

Parameters:

Name Type Description Default
data DataFrame

A tidy table with one row per sample size (per group, if grouped).

required
n str

Name of the sample-size column.

'n'
power str

Name of the power column, on the 0-1 scale.

'power'
group str

A column mapped to colour, one curve per level.

None
title str

Plot title.

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)
Source code in src/depictr/power.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
92
93
94
def power_curve_plot(data, n="n", power="power", group=None, title=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 0.8 (the conventional target). Where the
    curve crosses the line is the sample size that reaches adequate power. Pass a
    ``group`` to overlay one coloured curve per condition (for example several
    effect sizes or designs).

    Parameters
    ----------
    data : pandas.DataFrame
        A tidy table with one row per sample size (per group, if grouped).
    n : str
        Name of the sample-size column.
    power : str
        Name of the power column, on the 0-1 scale.
    group : str, optional
        A column mapped to colour, one curve per level.
    title : str, optional
        Plot title.

    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)
    """
    for col in (n, power, *( (group,) if group else () )):
        if col not in data.columns:
            raise KeyError(f"{col!r} is not a column of `data`.")

    mapping = (aes(x=n, y=power, color=group, group=group) if group
               else aes(x=n, y=power))
    p = ggplot(data, mapping)
    p = p + geom_hline(yintercept=_TARGET_POWER, linetype="dashed",
                       color="#9e9e9e")
    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="Power",
               color=group if group else None, 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.

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

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
 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
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.

    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

    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
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
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, title=None)

Horizontal bar chart of the variance inflation factor per predictor.

The variance inflation factor (VIF) measures how much a coefficient's variance is inflated by collinearity with the other predictors. A VIF of 1 means no collinearity; the reference line sits at 5, a common threshold above which collinearity is usually treated as a concern. The intercept is dropped, as its VIF is not interpretable in the usual way.

Parameters:

Name Type Description Default
model statsmodels results object

A fitted OLS/GLM result with a design matrix of two or more predictors.

required
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.vif_plot(model)
Source code in src/depictr/diagnostics.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
def vif_plot(model, title=None):
    """Horizontal bar chart of the variance inflation factor per predictor.

    The variance inflation factor (VIF) measures how much a coefficient's
    variance is inflated by collinearity with the other predictors. A VIF of 1
    means no collinearity; the reference line sits at 5, a common threshold above
    which collinearity is usually treated as a concern. The intercept is dropped,
    as its VIF is not interpretable in the usual way.

    Parameters
    ----------
    model : statsmodels results object
        A fitted OLS/GLM result with a design matrix of two or more predictors.
    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.vif_plot(model)
    """
    _require_statsmodels()
    from statsmodels.stats.outliers_influence import variance_inflation_factor

    if not _is_fitted_model(model):
        raise TypeError("vif_plot needs a fitted statsmodels result.")

    exog = np.asarray(model.model.exog)
    names = list(model.model.exog_names)
    rows = []
    for i, name in enumerate(names):
        if name.lower() in {"intercept", "const"}:
            continue
        rows.append({"term": name, "vif": variance_inflation_factor(exog, i)})
    if not rows:
        raise ValueError("Need at least one non-intercept predictor for a VIF.")
    df = pd.DataFrame(rows).sort_values("vif")
    # 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=5, linetype="dashed", color=ACCENT)
        + coord_flip()
        + labs(x="", y="Variance inflation factor", title=title)
        + theme_depictr(grid="x")
    )

binned_residual_plot

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

Binned residual plot for a binomial GLM (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 grey band is plus or minus two standard errors of the bin mean; under a well-fitting model about 95% of the points fall inside it. A run of points outside the band, or a clear trend in them, points to a missing predictor or the wrong functional form.

Parameters:

Name Type Description Default
model statsmodels results object

A fitted binomial GLM (logistic regression).

required
n_bins int

Number of bins. Defaults to the square root of the sample size, rounded, the rule of thumb in Gelman & Hill.

None
title str

Plot title.

None

Returns:

Type Description
ggplot
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
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
def binned_residual_plot(model, n_bins=None, title=None):
    """Binned residual plot for a binomial GLM (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 grey band is plus or
    minus two standard errors of the bin mean; under a well-fitting model about
    95% of the points fall inside it. A run of points outside the band, or a clear
    trend in them, points to a missing predictor or the wrong functional form.

    Parameters
    ----------
    model : statsmodels results object
        A fitted binomial GLM (logistic regression).
    n_bins : int, optional
        Number of bins. Defaults to the square root of the sample size, rounded,
        the rule of thumb in Gelman & Hill.
    title : str, optional
        Plot title.

    Returns
    -------
    plotnine.ggplot

    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)
    """
    sm = _require_statsmodels()
    if not _is_fitted_model(model):
        raise TypeError("binned_residual_plot needs a fitted statsmodels GLM.")
    # OLS results have no `family`; only a binomial GLM is meaningful here.
    family = getattr(model, "family", None)
    if not isinstance(family, sm.families.Binomial):
        raise ValueError(
            "binned_residual_plot expects a binomial GLM "
            "(family=sm.families.Binomial())."
        )

    fitted = np.asarray(model.fittedvalues)
    resid = np.asarray(model.resid_response)
    n = len(fitted)
    if n_bins is None:
        n_bins = max(int(round(np.sqrt(n))), 1)
    n_bins = min(n_bins, n)

    # Equal-count bins: rank the fitted values, then split into n_bins groups.
    order = np.argsort(fitted)
    bin_id = np.empty(n, dtype=int)
    bin_id[order] = np.floor(np.arange(n) / n * n_bins).astype(int)

    rows = []
    for b in range(n_bins):
        mask = bin_id == b
        if not mask.any():
            continue
        r = resid[mask]
        mean_fitted = fitted[mask].mean()
        mean_resid = r.mean()
        # Two-SE band: the standard error of a binomial residual scales with the
        # spread of the fitted probabilities in the bin (Gelman & Hill, 2007).
        se = 2 * np.sqrt(np.mean(fitted[mask] * (1 - fitted[mask])) / mask.sum())
        rows.append({"fitted": mean_fitted, "resid": mean_resid,
                     "lower": -se, "upper": se})
    df = pd.DataFrame(rows).sort_values("fitted")

    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)

Four-panel residual report for a fitted OLS or GLM.

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.

Parameters:

Name Type Description Default
model statsmodels results object

A fitted OLS/GLM result, the kind returned by 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

Returns:

Type Description
Compose

A 2x2 composition with .draw and .save.

References

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

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)
Source code in src/depictr/diagnostics_panels.py
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
def residual_diagnostics_plot(model, title=None):
    """Four-panel residual report for a fitted OLS or GLM.

    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.

    Parameters
    ----------
    model : statsmodels results object
        A fitted OLS/GLM result, the kind returned by ``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.

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

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

    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)
    """
    _check_model(model, "residual_diagnostics_plot")
    return arrange_plots(
        _residuals_vs_fitted(model, title="Residuals vs fitted"),
        _scale_location(model, title="Scale-location"),
        qq_plot(model, title="Normal Q-Q"),
        _residuals_vs_leverage(model, title="Residuals vs leverage"),
        ncol=2,
        title=title,
    )

model_report

model_report(model, title=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, the residuals-vs-fitted plot, 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.

Parameters:

Name Type Description Default
model statsmodels results object

A fitted OLS/GLM 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

Returns:

Type Description
Compose

A composition with .draw and .save.

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)
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
def model_report(model, title=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,
    the residuals-vs-fitted plot, 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`.

    Parameters
    ----------
    model : statsmodels results object
        A fitted OLS/GLM 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.

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

    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)
    """
    _check_model(model, "model_report")
    return arrange_plots(
        coefficient_plot(model, title="Coefficients"),
        _residuals_vs_fitted(model, title="Residuals vs fitted"),
        qq_plot(model, title="Normal Q-Q"),
        ncol=3,
        title=title,
    )

Classification

The standard classification curves and tables, computed by scikit-learn and redrawn under the shared theme.

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 (0/1). 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
 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 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 (0/1). 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()
    _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 (0/1). 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
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
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 (0/1). 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()
    _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.

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
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
def confusion_matrix_plot(y_true, y_pred, normalise=None, title=None):
    """Confusion-matrix heatmap.

    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)
    return (
        ggplot(long, aes("predicted", "true", fill="count"))
        + geom_tile(color="white")
        + geom_text(aes(label="label"), color="#1a1a1a", size=10)
        + scale_fill_gradientn(colors=depictr_palette(7, kind="sequential"),
                               name=("Proportion" if normalise else "Count"))
        + labs(x="Predicted", y="Actual", title=title)
        + theme_depictr(grid="none")
    )

calibration_plot

calibration_plot(y_true, y_score, n_bins=10, title=None)

Reliability (calibration) curve of predicted vs observed frequencies.

Parameters:

Name Type Description Default
y_true array - like

Binary outcomes (0/1).

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.

required
n_bins int

Number of equal-width bins spanning 0 to 1. Empty bins are dropped, so a rare outcome whose scores never approach 1 leaves fewer points than bins requested.

10
title str
None

Returns:

Type Description
ggplot

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
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
def calibration_plot(y_true, y_score, n_bins=10, title=None):
    """Reliability (calibration) curve of predicted vs observed frequencies.

    Parameters
    ----------
    y_true : array-like
        Binary outcomes (0/1).
    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.
    n_bins : int
        Number of equal-width bins spanning 0 to 1. Empty bins are dropped, so
        a rare outcome whose scores never approach 1 leaves fewer points than
        bins requested.
    title : str, optional

    Returns
    -------
    plotnine.ggplot

    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)
    """
    _require_sklearn()
    from sklearn.calibration import calibration_curve

    prob_true, prob_pred = calibration_curve(y_true, y_score, n_bins=n_bins)
    df = pd.DataFrame({"predicted": prob_pred, "observed": prob_true})
    return (
        ggplot(df, aes("predicted", "observed"))
        + geom_abline(intercept=0, slope=1, linetype="dashed", color="#9e9e9e")
        + geom_line(color=BRAND, size=0.9)
        + 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.

Parameters:

Name Type Description Default
y_true array - like

Binary outcomes (0/1). Both classes must be present: with no positives the share captured has no denominator, and with no negatives every depth captures everything.

required
y_score array - like

Predicted scores or probabilities for the positive class. Only the ranking matters, so any monotone score works.

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.gain_plot(ct["adverse_event"], score)
Source code in src/depictr/classification.py
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
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.

    Parameters
    ----------
    y_true : array-like
        Binary outcomes (0/1). Both classes must be present: with no positives
        the share captured has no denominator, and with no negatives every depth
        captures everything.
    y_score : array-like
        Predicted scores or probabilities for the positive class. Only the
        ranking matters, so any monotone score works.
    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.gain_plot(ct["adverse_event"], score)
    """
    y_true = np.asarray(y_true)
    n_pos = _positive_count(y_true,
                            "Gains/lift need both positive and negative outcomes.")
    order = np.argsort(-np.asarray(y_score))
    captured = np.cumsum(y_true[order]) / n_pos
    population = np.arange(1, len(y_true) + 1) / len(y_true)
    df = pd.DataFrame({
        "population": np.concatenate([[0], population]),
        "captured": np.concatenate([[0], 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.

Parameters:

Name Type Description Default
y_true array - like

Binary outcomes (0/1). 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.

required
y_score array - like

Predicted scores or probabilities for the positive class. Only the ranking matters, so any monotone score works.

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
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
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.

    Parameters
    ----------
    y_true : array-like
        Binary outcomes (0/1). 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.
    y_score : array-like
        Predicted scores or probabilities for the positive class. Only the
        ranking matters, so any monotone score works.
    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)
    """
    y_true = np.asarray(y_true)
    n_pos = _positive_count(y_true,
                            "Gains/lift need both positive and negative outcomes.")
    order = np.argsort(-np.asarray(y_score))
    population = np.arange(1, len(y_true) + 1) / len(y_true)
    captured = np.cumsum(y_true[order]) / n_pos
    df = pd.DataFrame({"population": population, "lift": captured / population})
    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.

Parameters:

Name Type Description Default
y_true array - like

Binary outcomes (0/1). Both classes must be present: with only one, sensitivity or specificity has no denominator at any threshold.

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.threshold_plot(ct["adverse_event"], score)
Source code in src/depictr/classification.py
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
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.

    Parameters
    ----------
    y_true : array-like
        Binary outcomes (0/1). Both classes must be present: with only one,
        sensitivity or specificity has no denominator at any threshold.
    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.threshold_plot(ct["adverse_event"], score)
    """
    _require_sklearn()
    y_true = np.asarray(y_true)
    y_score = np.asarray(y_score)
    _positive_count(y_true,
                    "A threshold sweep needs both positive and negative outcomes.")
    thresholds = np.unique(y_score)
    if len(thresholds) > 200:  # keep the sweep cheap on large score sets
        thresholds = np.quantile(y_score, np.linspace(0, 1, 200))
    n_pos = int((y_true == 1).sum())
    n_neg = int((y_true == 0).sum())
    rows = []
    for t in thresholds:
        pred = y_score >= t
        tp = int(np.sum(pred & (y_true == 1)))
        fp = int(np.sum(pred & (y_true == 0)))
        sens = tp / n_pos if n_pos else np.nan
        spec = (n_neg - fp) / n_neg if n_neg else np.nan
        prec = tp / (tp + fp) if (tp + fp) else np.nan
        denom = (prec + sens) if (prec + sens) > 0 else np.nan
        f1 = 2 * prec * sens / denom
        rows.append({"threshold": float(t), "Sensitivity": sens,
                     "Specificity": spec, "Precision": prec, "F1": f1})
    long = (pd.DataFrame(rows)
            .melt(id_vars="threshold", var_name="metric", value_name="value"))
    return (
        ggplot(long, aes("threshold", "value", color="metric"))
        + geom_line(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, and the audit that checks a finished figure rather than the palette it was built from.

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')

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"

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")
Source code in src/depictr/theme.py
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
def theme_depictr(base_size: float = 11, grid: str = "xy") -> 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.

    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")
    """
    if grid not in {"xy", "x", "y", "none"}:
        raise ValueError("`grid` must be 'xy', 'x', 'y' or 'none'.")

    th = theme_minimal(base_size=base_size) + 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, **kwargs)

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

Parameters:

Name Type Description Default
n int

Number of colours to draw. Defaults to the full qualitative palette; pass n when a plot has more groups than the eight base colours so the palette is interpolated to fit. 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.

None
**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())
Source code in src/depictr/theme.py
 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
def scale_colour_depictr(n: int | None = None, **kwargs):
    """A discrete colour scale drawn from :func:`depictr.palette.depictr_palette`.

    Parameters
    ----------
    n : int, optional
        Number of colours to draw. Defaults to the full qualitative palette;
        pass ``n`` when a plot has more groups than the eight base colours so the
        palette is interpolated to fit. 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.
    **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())
    """
    kwargs.setdefault("na_value", NA_VALUE)
    return scale_color_manual(values=depictr_palette(n), **kwargs)

scale_color_depictr module-attribute

scale_color_depictr = scale_colour_depictr

scale_fill_depictr

scale_fill_depictr(n=None, **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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def scale_fill_depictr(n: int | None = None, **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 scale_fill_manual(values=depictr_palette(n), **kwargs)

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
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
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')

Return a depictr palette.

Parameters:

Name Type Description Default
n int

Number of colours to return. 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"

Returns:

Type Description
list of str

Hex colour codes.

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.

Examples:

>>> import depictr as dp
>>> dp.depictr_palette(3)
['#005b96', '#e69f00', '#009e73']
Source code in src/depictr/palette.py
 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
def depictr_palette(n: int | None = None, kind: str = "qualitative") -> list[str]:
    """Return a depictr palette.

    Parameters
    ----------
    n : int, optional
        Number of colours to return. 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.

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

    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.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.depictr_palette(3)
    ['#005b96', '#e69f00', '#009e73']
    """
    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'."
        )
    if n is None:
        return list(OKABE_ITO)
    if n <= len(OKABE_ITO):
        return OKABE_ITO[: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(OKABE_ITO, 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
55
56
57
58
59
60
61
62
63
64
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.

Examples:

>>> import depictr as dp
>>> dp.depictr_accent()
'#e69f00'
Source code in src/depictr/palette.py
67
68
69
70
71
72
73
74
75
76
def depictr_accent() -> str:
    """Return the depictr accent colour as a hex string.

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

check_figure

check_figure(plot, width_cm=17.78, render_width_cm=17.78, min_delta_e=5, min_text_pt=6)

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. 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.

colour_separability is the smallest CIE76 colour difference (Delta-E) between any two of the figure's encoding colours, and 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 a layer uses to tell groups apart; a continuous colour or fill scale is a smooth ramp rather than a set of codes, so it is excluded.

greyscale_separability is the smallest difference in CIE lightness between those same colours, which is what survives printing in black and white.

text_size is the smallest point size of any text the figure draws, after scaling by width_cm / render_width_cm: a figure drawn seven inches wide and printed in an 8.9 cm column has every point size halved. Text drawn inside the panel, by a layer such as geom_text or by an annotation, is deliberately left out. It sits on the marks rather than on the background, so there is no one background to measure its contrast against, and the two engines size a layer's text in different units, which would leave the check disagreeing with its R twin on the same figure.

text_contrast and geometry_contrast are the smallest WCAG 2.x contrast ratios of, respectively, any drawn text against the plot background and any encoding colour against the panel background.

redundant_encoding counts how many of shape and line type also vary in a layer whose colour varies. Zero means the distinction between groups is carried by colour alone, the single most common way an otherwise careful figure becomes unreadable.

A limitation 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.

Parameters:

Name Type Description Default
plot ggplot

A plot, as returned by any depictr plotting function, including one extended afterwards with +. A multi-panel composite is refused: its panels have their own scales, themes and text, and one table of numbers cannot describe them all. Check each panel on its own.

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 CIE76 colour difference, used for the colour and greyscale separability checks. Defaults to 5, matching :func:depictr.palette_safety.

5
min_text_pt float

The smallest acceptable printed text size, in points. Defaults to 6, a common publisher floor for figure text.

6

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.

See Also

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

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())
>>> report = dp.check_figure(good)
>>> set(report["verdict"])
{'pass'}

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'
Source code in src/depictr/audit.py
 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
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) -> 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.
    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.

    ``colour_separability`` is the smallest CIE76 colour difference (Delta-E)
    between any two of the figure's encoding colours, and 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 a layer uses to tell groups
    apart; a continuous colour or fill scale is a smooth ramp rather than a set
    of codes, so it is excluded.

    ``greyscale_separability`` is the smallest difference in CIE lightness
    between those same colours, which is what survives printing in black and
    white.

    ``text_size`` is the smallest point size of any text the figure draws, after
    scaling by ``width_cm / render_width_cm``: a figure drawn seven inches wide
    and printed in an 8.9 cm column has every point size halved. Text drawn
    inside the panel, by a layer such as ``geom_text`` or by an annotation, is
    deliberately left out. It sits on the marks rather than on the background,
    so there is no one background to measure its contrast against, and the two
    engines size a layer's text in different units, which would leave the check
    disagreeing with its R twin on the same figure.

    ``text_contrast`` and ``geometry_contrast`` are the smallest WCAG 2.x
    contrast ratios of, respectively, any drawn text against the plot background
    and any encoding colour against the panel background.

    ``redundant_encoding`` counts how many of shape and line type also vary in a
    layer whose colour varies. Zero means the distinction between groups is
    carried by colour alone, the single most common way an otherwise careful
    figure becomes unreadable.

    **A limitation 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.

    Parameters
    ----------
    plot : plotnine.ggplot
        A plot, as returned by any depictr plotting function, including one
        extended afterwards with ``+``. A multi-panel composite is refused: its
        panels have their own scales, themes and text, and one table of numbers
        cannot describe them all. Check each panel on its own.
    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 CIE76 colour difference, used for the colour and
        greyscale separability checks. Defaults to 5, matching
        :func:`depictr.palette_safety`.
    min_text_pt : float
        The smallest acceptable printed text size, in points. Defaults to 6, a
        common publisher floor for figure text.

    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.

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

    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())
    >>> report = dp.check_figure(good)
    >>> set(report["verdict"])
    {'pass'}

    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'
    """
    from plotnine import ggplot
    from plotnine.composition import Compose

    # A composite has several panels, each with its own scales, theme and text.
    # One table of numbers cannot describe them all.
    if isinstance(plot, Compose):
        raise TypeError(
            "`plot` is a multi-panel composite. Check each panel on its own."
        )
    if not isinstance(plot, ggplot):
        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")

    # Drawing mutates the plot it is given (it builds the layers in place), so
    # the audit works on a copy and leaves the caller's figure untouched.
    built = copy.deepcopy(plot)
    figure = built.draw(show=False)
    try:
        plot_bg = _plot_background(built, figure) or "#ffffff"
        panel_bg = (_artist_background(built.axs[0].patch) if built.axs
                    else None) or plot_bg
        colours, redundant = _figure_colour_encoding(built)
        text = _figure_text(figure)
    finally:
        plt.close(figure)

    rows = _separability_rows(colours, min_delta_e)
    rows.append(_text_size_row(text, width_cm, render_width_cm, min_text_pt))
    rows.append(_text_contrast_row(text, plot_bg))
    rows.append(_geometry_contrast_row(colours, panel_bg))
    rows.append(_redundant_encoding_row(colours, redundant))
    return pd.DataFrame(
        rows, columns=["check", "measured", "threshold", "verdict", "detail"]
    )

palette_safety

palette_safety(colours=None, threshold=5.0)

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 CIE Lab* and the smallest pairwise colour difference (CIE76 Delta-E) is found. 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.

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 Delta-E 7.4 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 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.

None
threshold float

The smallest acceptable Delta-E.

5.0

Returns:

Type Description
dict

min_delta_e (the worst case across conditions), by_condition (the minimum Delta-E 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.

Examples:

>>> import depictr as dp
>>> dp.palette_safety()['safe']
True
Source code in src/depictr/cvd.py
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
def palette_safety(colours: list[str] | None = None, threshold: float = 5.0) -> 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 CIE L*a*b* and the smallest pairwise colour difference
    (CIE76 Delta-E) is found. 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``.

    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 Delta-E 7.4 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 : 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.
    threshold : float
        The smallest acceptable Delta-E.

    Returns
    -------
    dict
        ``min_delta_e`` (the worst case across conditions), ``by_condition``
        (the minimum Delta-E 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.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.palette_safety()['safe']
    True
    """
    from .palette import depictr_palette

    # `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 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)
    for deficiency in DEFICIENCIES:
        sim = simulate_cvd(colours, deficiency, severity=1.0)
        by_condition[deficiency], pairs[deficiency] = _min_pairwise_delta_e(sim)
    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 list of str

Colours (any matplotlib-readable form) to transform.

required
deficiency ('protan', 'deutan', 'tritan')

The deficiency to simulate (red-, green- or blue-weak vision).

"protan"
severity float

Severity in [0, 1]; 0 leaves the colours unchanged and 1 is the full deficiency. Intermediate values interpolate the transform towards the identity, an approximation to Machado et al.'s per-severity matrices.

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']
Source code in src/depictr/cvd.py
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
def simulate_cvd(colours: list[str], deficiency: str, severity: float = 1.0) -> list[str]:
    """Simulate how a palette appears under a colour-vision deficiency.

    Parameters
    ----------
    colours : list of str
        Colours (any matplotlib-readable form) to transform.
    deficiency : {"protan", "deutan", "tritan"}
        The deficiency to simulate (red-, green- or blue-weak vision).
    severity : float
        Severity in [0, 1]; 0 leaves the colours unchanged and 1 is the full
        deficiency. Intermediate values interpolate the transform towards the
        identity, an approximation to Machado et al.'s per-severity matrices.

    Returns
    -------
    list of str
        The simulated colours as hex strings.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.simulate_cvd(['#005b96', '#e69f00'], 'deutan')
    ['#275295', '#cab411']
    """
    if deficiency not in _MACHADO_1:
        raise ValueError(
            "`deficiency` must be one of 'protan', 'deutan' or 'tritan'."
        )
    if not 0 <= severity <= 1:
        raise ValueError("`severity` must lie in [0, 1].")
    matrix = (1 - severity) * np.eye(3) + severity * _MACHADO_1[deficiency]
    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]

Composition and data

Compose several plots into one figure or save one to disk, and the reproducibly simulated datasets used throughout the documentation.

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)

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.diagnostics.residual_diagnostics_plot).

Parameters:

Name Type Description Default
*plots ggplot

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

Returns:

Type Description
ggplot or Compose

A single plot when one is passed, otherwise a composition.

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)
Source code in src/depictr/compose.py
 9
10
11
12
13
14
15
16
17
18
19
20
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
def arrange_plots(*plots, ncol: int | None = None, nrow: int | None = None,
                  title: str | 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.diagnostics.residual_diagnostics_plot`).

    Parameters
    ----------
    *plots : plotnine.ggplot
        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.

    Returns
    -------
    plotnine.ggplot or plotnine.composition.Compose
        A single plot when one is passed, otherwise a composition.

    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)
    """
    from plotnine import ggplot, ggtitle

    items = [p for p in plots if p is not None]
    if not items:
        raise ValueError("arrange_plots needs at least one plot.")
    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', **kwargs)

Save a depictr/plotnine plot at publication resolution.

Parameters:

Name Type Description Default
plot ggplot

The plot to save.

required
filename str

Output path; the extension sets the format (.png, .pdf, .svg).

required
width float

Figure size, in units.

7
height float

Figure size, in units.

7
dpi int

Dots per inch (300 suits print).

300
units str

Size units ("in", "cm" or "mm").

'in'
**kwargs

Passed to :meth:plotnine.ggplot.save.

{}

Returns:

Type Description
str

The filename written.

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
Source code in src/depictr/compose.py
 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
def save_plot(plot, filename, width: float = 7, height: float = 4.5,
              dpi: int = 300, units: str = "in", **kwargs) -> str:
    """Save a depictr/plotnine plot at publication resolution.

    Parameters
    ----------
    plot : plotnine.ggplot
        The plot to save.
    filename : str
        Output path; the extension sets the format (``.png``, ``.pdf``, ``.svg``).
    width, height : float
        Figure size, in ``units``.
    dpi : int
        Dots per inch (300 suits print).
    units : str
        Size units (``"in"``, ``"cm"`` or ``"mm"``).
    **kwargs
        Passed to :meth:`plotnine.ggplot.save`.

    Returns
    -------
    str
        The filename written.

    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
    """
    plot.save(filename, width=width, height=height, dpi=dpi, units=units,
              verbose=False, **kwargs)
    return filename

crop_yield

crop_yield(seed=1)

A field trial with a genuine fertiliser-by-treatment interaction.

Examples:

>>> import depictr as dp
>>> dp.crop_yield().shape
(200, 5)
Source code in src/depictr/data.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
def crop_yield(seed: int = 1) -> pd.DataFrame:
    """A field trial with a genuine fertiliser-by-treatment interaction.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.crop_yield().shape
    (200, 5)
    """
    rng = np.random.default_rng(seed)
    n = 200
    treatment = rng.choice(["standard", "enhanced"], n)
    fertiliser = rng.uniform(0, 150, n)
    rainfall = rng.normal(500, 80, n)
    soil_ph = rng.normal(6.5, 0.6, n)
    slope = np.where(treatment == "enhanced", 0.014, 0.007)
    yield_ = (
        1.5
        + slope * fertiliser
        + 0.002 * (rainfall - 500)
        + 0.3 * (soil_ph - 6.5)
        + rng.normal(0, 0.8, n)
    )
    return pd.DataFrame({
        "fertiliser": fertiliser, "rainfall": rainfall, "soil_ph": soil_ph,
        "treatment": treatment, "yield": yield_,
    })

wellbeing_survey

wellbeing_survey(seed=2)

A cross-sectional survey with regional contrasts and informative missingness.

Examples:

>>> import depictr as dp
>>> dp.wellbeing_survey().shape
(300, 7)
Source code in src/depictr/data.py
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
def wellbeing_survey(seed: int = 2) -> pd.DataFrame:
    """A cross-sectional survey with regional contrasts and informative missingness.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.wellbeing_survey().shape
    (300, 7)
    """
    rng = np.random.default_rng(seed)
    n = 300
    region = rng.choice(["North", "South", "East", "West"], n)
    age = rng.integers(18, 80, n)
    education = rng.choice(["secondary", "undergraduate", "postgraduate"], n,
                           p=[0.45, 0.4, 0.15])
    # The regions differ genuinely (more stress and lower incomes in the South
    # and West), so region-grouped plots compare real contrasts, not noise.
    stress_shift = pd.Series(region).map(
        {"North": -1.2, "South": 1.2, "East": -0.4, "West": 0.6}).to_numpy()
    income_shift = pd.Series(region).map(
        {"North": 7000.0, "South": -6000.0, "East": 2500.0, "West": -2500.0}).to_numpy()
    stress = (rng.normal(5, 2, n) + stress_shift).clip(0, 10)
    sleep_hours = (8 - 0.2 * stress + rng.normal(0, 0.8, n)).clip(3, 11)
    income = (30000 + 800 * age - 1500 * stress + income_shift
              + rng.normal(0, 6000, n)).clip(0, None)
    life_satisfaction = (5 - 0.3 * stress + 0.2 * sleep_hours
                         + rng.normal(0, 0.5, n)).clip(1, 7)
    df = pd.DataFrame({
        "region": region, "age": age, "education": education, "income": income,
        "stress": stress, "sleep_hours": sleep_hours,
        "life_satisfaction": life_satisfaction,
    })
    # Informative missingness: income is missing more often at higher stress.
    p_missing = (0.02 + 0.02 * (df["stress"] > 6)).to_numpy()
    df.loc[rng.random(n) < p_missing, "income"] = np.nan
    df.loc[rng.random(n) < 0.06, "sleep_hours"] = np.nan
    df.loc[rng.random(n) < 0.04, "life_satisfaction"] = np.nan
    return df

lexical_decision

lexical_decision(seed=3)

A priming reaction-time/accuracy experiment.

Examples:

>>> import depictr as dp
>>> dp.lexical_decision().shape
(600, 5)
Source code in src/depictr/data.py
 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
def lexical_decision(seed: int = 3) -> pd.DataFrame:
    """A priming reaction-time/accuracy experiment.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.lexical_decision().shape
    (600, 5)
    """
    rng = np.random.default_rng(seed)
    n = 600
    condition = rng.choice(["related", "unrelated"], n)
    modality = rng.choice(["visual", "auditory"], n)
    word_frequency = rng.uniform(1, 6, n)
    rt = (
        650
        + 30 * (condition == "unrelated")
        + 25 * (modality == "auditory")
        - 20 * word_frequency
        + rng.gamma(2, 35, n)
    )
    p_correct = 1 / (1 + np.exp(-(2 + 0.3 * word_frequency - 0.4 * (condition == "unrelated"))))
    accuracy = (rng.random(n) < p_correct).astype(int)
    return pd.DataFrame({
        "condition": condition, "modality": modality,
        "word_frequency": word_frequency, "RT": rt, "accuracy": accuracy,
    })

clinical_trial

clinical_trial(seed=4)

A two-arm trial with separating survival curves and a rare adverse event.

Examples:

>>> import depictr as dp
>>> dp.clinical_trial().shape
(300, 6)
Source code in src/depictr/data.py
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
def clinical_trial(seed: int = 4) -> pd.DataFrame:
    """A two-arm trial with separating survival curves and a rare adverse event.

    Examples
    --------
    >>> import depictr as dp
    >>> dp.clinical_trial().shape
    (300, 6)
    """
    rng = np.random.default_rng(seed)
    n = 300
    arm = rng.choice(["control", "treatment"], n)
    biomarker = rng.normal(0, 1, n)
    age = rng.integers(40, 85, n)
    hazard = np.where(arm == "treatment", 0.05, 0.1) * np.exp(0.2 * biomarker)
    true_time = rng.exponential(1 / hazard)
    censor = rng.uniform(0, 36, n)
    time = np.minimum(true_time, censor)
    event = (true_time <= censor).astype(int)
    # The adverse event stays rare (~14%) but is genuinely predictable from the
    # biomarker, age and arm, so the classification demos have real signal.
    p_adverse = 1 / (1 + np.exp(-(-3.2 + 1.2 * biomarker + 0.05 * (age - 60)
                                  + 0.7 * (arm == "treatment"))))
    adverse_event = (rng.random(n) < p_adverse).astype(int)
    return pd.DataFrame({
        "time": time, "event": event, "arm": arm, "biomarker": biomarker,
        "age": age, "adverse_event": adverse_event,
    })

DATASETS module-attribute

DATASETS = {'crop_yield': crop_yield, 'wellbeing_survey': wellbeing_survey, 'lexical_decision': lexical_decision, 'clinical_trial': clinical_trial}