Seaborn Objects Interface
seaborn objects Interface
Core Principle: The
seaborn.objectsinterface implements a declarative grammar of graphics, assembling visualizations through modular layers of marks, statistical transforms, scales, and spatial coordinate moves.
Introduced in seaborn 0.12 (2022), still marked experimental as of 0.13.2. Worth knowing because it is the direction seaborn is heading, and because it expresses some plots much more cleanly than the classic API.
The idea
Instead of choosing a function (scatterplot, lineplot, boxplot), you declare a data-to-visual mapping and add layers:
import seaborn.objects as so
(
so.Plot(df, x="log2fc", y="neglog10p", color="significant")
.add(so.Dots(pointsize=3, alpha=0.5))
.label(x="log$_2$ fold change", y="$-$log$_{10}$ $p_{adj}$")
.theme({"axes.grid": True})
.save("volcano.pdf", bbox_inches="tight")
)
Anyone who has used ggplot2 will recognise this immediately. It is the same grammar: data + aesthetic mapping + geometric marks + statistical transforms + scales + facets.
Marks
so.Dot(); so.Dots() # points (Dots is the faster bulk version)
so.Line(); so.Lines()
so.Path() # line following data order, not sorted x
so.Area(); so.Band() # filled regions
so.Bar(); so.Bars()
so.Range() # error bars
so.Text()
Stats: transformations applied before drawing
so.Agg("mean") # aggregate y within x
so.Est("mean", errorbar="se") # aggregate + uncertainty
so.Hist(bins=50, stat="density")
so.KDE()
so.Count()
so.Perc([25, 50, 75])
so.PolyFit(order=2)
Moves: resolve overplotting
so.Dodge() # side by side
so.Jitter(0.3) # random offset
so.Stack() # stacked bars
so.Shift(x=0.1)
so.Dodge() + so.Jitter() # composable!
so.Dodge() + so.Jitter() composing is something the classic API cannot express: stripplot has dodge= and jitter= as separate flags with fixed interaction.
Layering
(
so.Plot(long, x="condition", y="expression", color="tissue")
.add(so.Dots(alpha=0.4), so.Jitter(0.2), so.Dodge()) # raw points
.add(so.Range(), so.Est("mean", errorbar="se"), so.Dodge()) # error bars
.add(so.Dot(pointsize=8), so.Agg("mean"), so.Dodge()) # means
.scale(color="colorblind")
.facet(col="gene", wrap=4)
.share(y=False)
.layout(size=(10, 6))
.label(y="log$_2$ CPM")
)
Each .add() is a layer with its own mark, stat and move. Building "points + mean + error bar, dodged by group, faceted by gene" is direct here and genuinely awkward in the classic API (three separate calls that must be given matching dodge and order arguments).
Scales
.scale(x="log", y="log")
.scale(color="viridis")
.scale(color=so.Continuous("rocket", norm=(0, 10)))
.scale(pointsize=so.Continuous().tick(at=[1, 10, 100]))
.scale(color=so.Nominal(["#4C72B0", "#DD8452"], order=["control", "treated"]))
Facets and pairs
.facet(col="gene", row="tissue", wrap=None)
.pair(x=["PC1", "PC2"], y=["PC3", "PC4"])
.share(x=True, y=False)
Output
p = so.Plot(...).add(...)
p.show()
p.save("out.pdf", bbox_inches="tight", dpi=300)
f = mpl.figure.Figure(figsize=(6, 4))
p.on(f).plot() # draw into an existing Figure
p.on(ax).plot() # or an existing Axes
.on() is how you compose an objects plot into a larger matplotlib layout: the equivalent of ax= in the classic API.
Classic vs objects
| Classic | objects | |
|---|---|---|
| Maturity | stable since 2012 | experimental since 2022 |
| Documentation, tutorials | abundant | growing |
| Learning curve | pick a function | learn the grammar |
| Common plots | one line | slightly more verbose |
| Layered / composite plots | awkward | natural |
| Consistency | some parameters differ per function | uniform |
| StackOverflow answers | thousands | few |
Recommendation for now: learn the classic API first: it is what tutorials, colleagues and error messages assume. Reach for objects when you hit a plot the classic API makes painful, or if you are coming from ggplot2 and the grammar is already in your head.
Bioinformatics examples
# expression by condition, faceted, with points and means
(
so.Plot(long, x="condition", y="log_cpm", color="tissue")
.add(so.Dots(alpha=0.3), so.Jitter(0.25), so.Dodge())
.add(so.Range(), so.Est("mean", errorbar="ci"), so.Dodge())
.facet(col="gene", wrap=4)
.share(y=False)
.label(y="log$_2$ CPM", x="")
)
# dose-response with a fitted curve over the raw points
(
so.Plot(dose, x="conc", y="viability", color="cell_line")
.add(so.Dots(alpha=0.5))
.add(so.Line(), so.PolyFit(order=3))
.scale(x="log")
.label(x="Concentration (µM)", y="Viability")
)
# stacked variant consequences per sample
(
so.Plot(variants, x="sample", color="consequence")
.add(so.Bars(), so.Count(), so.Stack())
.scale(color="Set2")
.layout(size=(10, 4))
)
# distribution comparison, density-normalised
(
so.Plot(long, x="log_cpm", color="condition")
.add(so.Area(alpha=0.4), so.KDE())
.label(x="log$_2$ CPM")
)
The third example: stacked counts by category: is one line here and needs a manual crosstab plus DataFrame.plot(kind="bar", stacked=True) in the classic API.
Common mistakes
- Expecting classic parameters to work.
hue=iscolor=;figsize=is.layout(size=...). - Forgetting the API is still experimental. It can change between minor versions; pin seaborn if you rely on it.
- Mixing
so.Plotwithplt.calls. Use.on(ax)to compose. .add()order. Later layers draw on top; put raw points before summaries.- Searching for help. Most seaborn answers online are for the classic API.
See also
Seaborn · Figure-level vs Axes-level · Common Plot Types · Statistical Estimation in seaborn · Faceting
Exercise
seaborn is not available in this sandbox, so compute by hand what
so.Plot(...).add(so.Bar(), so.Agg("mean"), so.Dodge()) would have drawn and
then draw it with matplotlib. Write dodged_bars(rows), where rows is a list
of (x, group, value) triples, one per input line. Take the x categories and
the groups in order of first appearance and give the x categories the integer
positions 0, 1, 2 and so on. Apply the stat first: average the values within
each (x, group) pair. Then apply the move: with a total slot width of 0.8 and
k groups, each bar is 0.8 / k wide and the group at index i sits at
-0.4 + (i + 0.5) * 0.8 / k relative to its x position. Draw each bar with
ax.bar, skipping any (x, group) combination the data does not contain: a
grid built by assuming every combination is present would misplace the
remaining bars.
Return bars=<n> x=<n> groups=<n> first, then one line per bar in x-major then
group order: the x label, the group label, the bar's centre and its height,
both rounded to three decimals. Read the centre and height back off the
Rectangle objects in ax.patches: get_x() gives the left edge, not the
centre. With a single group each bar is 0.8 wide and centred exactly on its x
position.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | TP53 control 1.0 TP53 control 1.4 TP53 treated 3.0 BRCA1 control 0.8 BRCA1 treated 0.9 BRCA1 treated 1.1 | bars=4 x=2 groups=2 TP53 control -0.2 1.2 TP53 treated 0.2 3.0 BRCA1 control 0.8 0.8 BRCA1 treated 1.2 1.0 |
| 2 | stdin | TP53 control 2.0 BRCA1 control 3.0 EGFR control 4.0 | bars=3 x=3 groups=1 TP53 control 0.0 2.0 BRCA1 control 1.0 3.0 EGFR control 2.0 4.0 |
| 3 | stdin | A x 1.0 A y 2.0 A z 3.0 B x 4.0 B z 6.0 | bars=5 x=2 groups=3 A x -0.267 1.0 A y 0.0 2.0 A z 0.267 3.0 B x 0.733 4.0 B z 1.267 6.0 |
| 4 | stdin | MYC treated 5.5 | bars=1 x=1 groups=1 MYC treated 0.0 5.5 |
Hints · 3
01Hint
The dodge offset is symmetric about zero: with k=2 the offsets are -0.2 and +0.2.
02Hint
`ax.bar` positions a bar by its centre, but `patch.get_x()` returns the left edge.
03Hint
Aggregate before drawing — one bar per (x, group) pair, not one per input row.