24  Faceting

Faceting splits data into subsets and draws each in its own panel, making it easy to compare patterns across groups.

A facet is a mapping: :col and :row are aesthetics, and pj/facet is the call that writes one. Everything else follows from that – a facet reaches into a composite, a cell can override the facet it inherits, and several columns under :col unite into one distinction. The sections below teach those ideas, and the Examples section at the end shows faceted plots of several kinds.

(ns plotje-book.faceting
  (:require
   ;; Tablecloth -- dataset manipulation
   [tablecloth.api :as tc]
   ;; Rdatasets -- standard datasets
   [scicloj.metamorph.ml.rdatasets :as rdatasets]
   ;; Kindly -- notebook rendering protocol
   [scicloj.kindly.v4.kind :as kind]
   ;; Plotje -- composable plotting
   [scicloj.plotje.api :as pj]))

Facet Wrap

pj/facet splits a pose into panels by one categorical column. The default direction is :col – a horizontal row of panels:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/facet :species))
sepal widthsepal lengthspeciessetosaversicolorvirginica682.02.53.03.54.04.56868setosaversicolorvirginica

Each species gets its own panel with a strip label on top. Scales are shared by default – all panels use the same x and y range, making direct comparison easy.

Vertical Facet and Facet Grid

Pass :row as the direction for a vertical column of panels:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/facet :species :row))
sepal widthsepal lengthspeciessetosaversicolorvirginica2342344.55.05.56.06.57.07.58.0234setosaversicolorvirginica

pj/facet-grid splits by two columns – one for rows, one for columns:

(-> (rdatasets/reshape2-tips)
    (pj/lay-point :total-bill :tip {:color :sex})
    (pj/facet-grid :smoker :sex))
tiptotal billsexFemaleMale51020405102040NoYesFemaleMale

Row labels appear on the right, column labels on top.

Faceting is a mapping

The panel aesthetics :col and :row give each value of a column a panel of its own, told apart by a strip label – where :color tells the marks apart by colour on one panel. They are documented here, where they are written, rather than beside the layer options, because no pj/lay-* call accepts one.

(kind/table
 {:column-names [:aesthetic :what-it-reads]
  :row-vectors (mapv (fn [[k doc]] [k doc])
                     (sort-by (comp str key) pj/panel-aesthetic-docs))})
aesthetic what-it-reads
col A column of the pose's data, or a vector of them, giving each value a panel of its own, laid out across. A vector unites the columns into a compound key, so a panel is drawn for each combination the data holds. Written by pj/facet, and by pj/facet-grid for the across direction.
row A column of the pose's data, or a vector of them, giving each value a panel of its own, laid out down. The same readings as :col. Written by (pj/facet my-pose col :row), and by pj/facet-grid for the down direction.

pj/facet writes :col into the pose’s mapping, beside :x and :y. Here is the plot, and then the pose that draws it.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width)
    (pj/facet :species))
sepal widthsepal length682.02.53.03.54.04.56868setosaversicolorvirginica
(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width)
    (pj/facet :species)
    kind/pprint)
{:layers [{:layer-type :point}],
 :data
 https://vincentarelbundock.github.io/Rdatasets/csv/datasets/iris.csv [150 6]:

| :rownames | :sepal-length | :sepal-width | :petal-length | :petal-width |  :species |
|----------:|--------------:|-------------:|--------------:|-------------:|-----------|
|         1 |           5.1 |          3.5 |           1.4 |          0.2 |    setosa |
|         2 |           4.9 |          3.0 |           1.4 |          0.2 |    setosa |
|         3 |           4.7 |          3.2 |           1.3 |          0.2 |    setosa |
|         4 |           4.6 |          3.1 |           1.5 |          0.2 |    setosa |
|         5 |           5.0 |          3.6 |           1.4 |          0.2 |    setosa |
|         6 |           5.4 |          3.9 |           1.7 |          0.4 |    setosa |
|         7 |           4.6 |          3.4 |           1.4 |          0.3 |    setosa |
|         8 |           5.0 |          3.4 |           1.5 |          0.2 |    setosa |
|         9 |           4.4 |          2.9 |           1.4 |          0.2 |    setosa |
|        10 |           4.9 |          3.1 |           1.5 |          0.1 |    setosa |
|       ... |           ... |          ... |           ... |          ... |       ... |
|       140 |           6.9 |          3.1 |           5.4 |          2.1 | virginica |
|       141 |           6.7 |          3.1 |           5.6 |          2.4 | virginica |
|       142 |           6.9 |          3.1 |           5.1 |          2.3 | virginica |
|       143 |           5.8 |          2.7 |           5.1 |          1.9 | virginica |
|       144 |           6.8 |          3.2 |           5.9 |          2.3 | virginica |
|       145 |           6.7 |          3.3 |           5.7 |          2.5 | virginica |
|       146 |           6.7 |          3.0 |           5.2 |          2.3 | virginica |
|       147 |           6.3 |          2.5 |           5.0 |          1.9 | virginica |
|       148 |           6.5 |          3.0 |           5.2 |          2.0 | virginica |
|       149 |           6.2 |          3.4 |           5.4 |          2.3 | virginica |
|       150 |           5.9 |          3.0 |           5.1 |          1.8 | virginica |
,
 :mapping {:x :sepal-length, :y :sepal-width, :col :species}}

(pj/facet my-pose col :row) writes :row instead, and pj/facet-grid writes both.

(-> (rdatasets/ggplot2-mpg)
    (pj/lay-point :displ :hwy)
    (pj/facet-grid :drv :cyl)
    kind/pprint)
{:layers [{:layer-type :point}],
 :data
 https://vincentarelbundock.github.io/Rdatasets/csv/ggplot2/mpg.csv [234 12]:

| :rownames | :manufacturer |     :model | :displ | :year | :cyl |     :trans | :drv | :cty | :hwy | :fl |     :class |
|----------:|---------------|------------|-------:|------:|-----:|------------|------|-----:|-----:|-----|------------|
|         1 |          audi |         a4 |    1.8 |  1999 |    4 |   auto(l5) |    f |   18 |   29 |   p |    compact |
|         2 |          audi |         a4 |    1.8 |  1999 |    4 | manual(m5) |    f |   21 |   29 |   p |    compact |
|         3 |          audi |         a4 |    2.0 |  2008 |    4 | manual(m6) |    f |   20 |   31 |   p |    compact |
|         4 |          audi |         a4 |    2.0 |  2008 |    4 |   auto(av) |    f |   21 |   30 |   p |    compact |
|         5 |          audi |         a4 |    2.8 |  1999 |    6 |   auto(l5) |    f |   16 |   26 |   p |    compact |
|         6 |          audi |         a4 |    2.8 |  1999 |    6 | manual(m5) |    f |   18 |   26 |   p |    compact |
|         7 |          audi |         a4 |    3.1 |  2008 |    6 |   auto(av) |    f |   18 |   27 |   p |    compact |
|         8 |          audi | a4 quattro |    1.8 |  1999 |    4 | manual(m5) |    4 |   18 |   26 |   p |    compact |
|         9 |          audi | a4 quattro |    1.8 |  1999 |    4 |   auto(l5) |    4 |   16 |   25 |   p |    compact |
|        10 |          audi | a4 quattro |    2.0 |  2008 |    4 | manual(m6) |    4 |   20 |   28 |   p |    compact |
|       ... |           ... |        ... |    ... |   ... |  ... |        ... |  ... |  ... |  ... | ... |        ... |
|       224 |    volkswagen | new beetle |    2.0 |  1999 |    4 | manual(m5) |    f |   21 |   29 |   r | subcompact |
|       225 |    volkswagen | new beetle |    2.0 |  1999 |    4 |   auto(l4) |    f |   19 |   26 |   r | subcompact |
|       226 |    volkswagen | new beetle |    2.5 |  2008 |    5 | manual(m5) |    f |   20 |   28 |   r | subcompact |
|       227 |    volkswagen | new beetle |    2.5 |  2008 |    5 |   auto(s6) |    f |   20 |   29 |   r | subcompact |
|       228 |    volkswagen |     passat |    1.8 |  1999 |    4 | manual(m5) |    f |   21 |   29 |   p |    midsize |
|       229 |    volkswagen |     passat |    1.8 |  1999 |    4 |   auto(l5) |    f |   18 |   29 |   p |    midsize |
|       230 |    volkswagen |     passat |    2.0 |  2008 |    4 |   auto(s6) |    f |   19 |   28 |   p |    midsize |
|       231 |    volkswagen |     passat |    2.0 |  2008 |    4 | manual(m6) |    f |   21 |   29 |   p |    midsize |
|       232 |    volkswagen |     passat |    2.8 |  1999 |    6 |   auto(l5) |    f |   16 |   26 |   p |    midsize |
|       233 |    volkswagen |     passat |    2.8 |  1999 |    6 | manual(m5) |    f |   18 |   26 |   p |    midsize |
|       234 |    volkswagen |     passat |    3.6 |  2008 |    6 |   auto(s6) |    f |   17 |   26 |   p |    midsize |
,
 :mapping {:x :displ, :y :hwy, :col :drv, :row :cyl}}

Any mapping value may be written as a map naming its source, and a panel aesthetic is no exception. These two draw the same plot.

(-> (rdatasets/datasets-iris)
    (pj/pose {:x :sepal-length :y :sepal-width :col {:column :species}})
    (pj/lay-point))
sepal widthsepal length682.02.53.03.54.04.56868setosaversicolorvirginica

What the scope rules give

Mappings flow downward: a mapping written on a pose reaches every layer and every sub-pose below it, and a sub-pose that writes its own key overrides what it inherits. A panel aesthetic is a mapping, so all of that applies to faceting.

A composite can be faceted

A facet written on an arranged pose divides every cell of it.

(-> (pj/arrange [[(pj/pose (rdatasets/datasets-iris) :sepal-length :sepal-width)]
                 [(pj/pose (rdatasets/datasets-iris) :petal-length :petal-width)]])
    (pj/lay-point)
    (pj/facet :species)
    (pj/options {:width 700 :height 560}))
sepal widthsepal length56782.02.53.03.54.04.556785678setosaversicolorvirginicapetal widthpetal length2460.51.01.52.02.5246246setosaversicolorvirginica

Two cells, three species, six panels. pj/arrange takes one vector per row, so [[a] [b]] stacks the two cells and each has the full width for its three panels.

One cell can be faceted differently from another

A cell that writes its own :col overrides the one it inherits, which is the rule for every mapping key. Here the top cell is divided by species and the bottom cell is not divided at all.

(-> (pj/arrange [[(-> (pj/pose (rdatasets/datasets-iris) :sepal-length :sepal-width)
                      (pj/facet :species))]
                 [(pj/pose (rdatasets/datasets-iris) :petal-length :petal-width)]])
    (pj/lay-point)
    (pj/options {:width 700 :height 560}))
sepal widthsepal length56782.02.53.03.54.04.556785678setosaversicolorvirginicapetal widthpetal length1.01.52.02.53.03.54.04.55.05.56.06.57.00.00.51.01.52.02.5

Three panels in the top row and one in the bottom row.

A facet multiplies the panels a pose already draws

A pose whose layers name different columns draws a panel for each place. Faceting such a pose draws every one of those panels once per value the column holds: the facet is the outer division.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width)
    (pj/lay-point :petal-length :petal-width)
    (pj/facet :species)
    (pj/options {:width 900 :height 480}))
2.02.53.03.54.04.52460.51.01.52.02.5246246setosaversicolorvirginicasepal widthpetal width

A marginal follows the division

pj/marginal builds a fresh leaf rather than a descendant, so the scope rules do not reach it on their own. It carries the panel aesthetics across itself, giving a faceted main panel a marginal above each of its panels. The strip label is drawn once per column, at the top.

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width)
    (pj/facet :species)
    (pj/marginal :top)
    (pj/options {:width 900 :height 480}))
0.00.51.0setosaversicolorvirginicasepal widthsepal length56782.02.53.03.54.04.556785678

Several columns as one key

Under some aesthetics, several columns in a vector are one distinction made of those columns united – a compound key. That is what {:group [:a :b]} means, and the panel aesthetics read a vector the same way. pj/compound-key-aesthetics names the set, and it is the set the check reads, so an aesthetic outside it reports several columns where one goes rather than uniting them:

(pj/compound-key-aesthetics)
#{:group :col :row}

The table below holds three of the four combinations of :part and :dimension: there is no petal width row.

(def measures
  (tc/dataset {:part      ["sepal" "sepal" "sepal" "sepal" "petal" "petal"]
               :dimension ["length" "length" "width" "width" "length" "length"]
               :t         [1 2 1 2 1 2]
               :v         [1.0 2.0 1.5 2.5 2.0 3.0]}))
measures

_unnamed [6 4]:

:part :dimension :t :v
sepal length 1 1.0
sepal length 2 2.0
sepal width 1 1.5
sepal width 2 2.5
petal length 1 2.0
petal length 2 3.0

:color is outside the set, because a mark has one colour. A vector written there is reported rather than united:

(try
  (-> measures
      (pj/lay-point :t :v {:color [:part :dimension]}))
  (catch clojure.lang.ExceptionInfo e
    (ex-message e)))
"lay-point :color was given several columns, [:part :dimension]. Several columns where one goes are read as several series, which belongs on :x or :y -- the aesthetics that name what a mark is drawn from. Written there, the key column the pivot invents is mapped to :color on its own."

A compound facet draws one panel per combination the data holds, labelled by each column’s value in turn.

(-> measures
    (pj/lay-point :t :v)
    (pj/facet [:part :dimension]))
vt121231212sepal / lengthsepal / widthpetal / length

pj/facet-grid crosses two distinctions and fills the rectangle, so the combination the data does not hold is drawn as an empty panel.

(-> measures
    (pj/lay-point :t :v)
    (pj/facet-grid :part :dimension))
vt01230120123no data012sepalpetallengthwidth

Free Scales (Independent Axis Ranges)

By default all panels share the same axis ranges. Use the :scales option to let axes vary per panel.

Shared (default) – all panels carry the same x and y ranges:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/facet :species)
    (pj/options {:scales :shared}))
sepal widthsepal lengthspeciessetosaversicolorvirginica682.02.53.03.54.04.56868setosaversicolorvirginica

Free y – each panel has its own y-range:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/facet :species)
    (pj/options {:scales :free-y}))
sepal widthsepal lengthspeciessetosaversicolorvirginica682.22.42.62.83.03.23.43.63.84.04.24.46868setosaversicolorvirginica

Other values: :free-x (x per-panel, y shared), :free (both axes per-panel).

Appearance Aesthetics Across Panels

:scales frees the axes, and the axes alone. A :size or :alpha column is read against every value the plot holds, whichever panel a mark sits in, so one value is one size everywhere and the legend explains every panel at once.

Below, the left panel’s values run from 1 to 3 and the right panel’s from 4 to 10, so every mark on the left is smaller than every mark on the right:

(def per-panel
  {:g ["L" "L" "L" "R" "R" "R"]
   :x [1 2 3 1 2 3]
   :y [1 1 1 1 1 1]
   :n [1 2 3 4 7 10]})
(-> per-panel
    (pj/lay-point :x :y {:size :n})
    (pj/facet :g))
yxn246810123012123LR

A :domain sets that one extent. Given more room than the data needs, no mark reaches either end of the range:

(-> per-panel
    (pj/lay-point :x :y {:size :n})
    (pj/facet :g)
    (pj/scale :size {:domain [0 20]}))
yxn05101520123012123LR

A related multi-panel layout, the scatter plot matrix (SPLOM), uses pj/cross rather than pj/facet – the panels show all pairs of variables instead of one variable split across panels. See the Relationships chapter for the canonical SPLOM example.

Where it is refused

A facet divides every layer of the pose alike, and a panel aesthetic is read from a pose’s mapping and not from a layer’s, so a panel aesthetic belongs on the pose. Written in a pj/lay-* options map it is reported, and the report names the places to write it instead.

(try
  (-> (rdatasets/datasets-iris)
      (pj/lay-point :sepal-length :sepal-width {:col :species})
      pj/plan)
  (catch Exception e (ex-message e)))
"A panel aesthetic col was written in a layer's options map. A facet divides every layer of the pose, and a panel aesthetic is read from a pose's mapping and not from a layer's, so write it on the pose: (pj/facet pose col), (pj/facet-grid pose col-col row-col), or in the pose's mapping. On a composite, write it on the cell whose panels it divides."

A second facet in the same direction on one pose is reported rather than replacing the first. A facet written on a cell of a composite overrides the facet the cell inherits, by the scope rules.

(try
  (-> (rdatasets/datasets-iris)
      (pj/lay-point :sepal-length :sepal-width)
      (pj/facet :species)
      (pj/facet :sepal-length))
  (catch Exception e (ex-message e)))
"pj/facet was given :sepal-length for :col, which this pose already facets by :species. A pose divides its panels once per direction: use the other direction, or pj/facet-grid for both at once, or facet a compound key -- (pj/facet my-pose [:species :sepal-length])."

Examples

Faceted plots of other kinds, each built from the ideas above.

Faceted Histogram

(-> (rdatasets/datasets-iris)
    (pj/lay-histogram :sepal-length {:color :species})
    (pj/facet :species))
sepal lengthspeciessetosaversicolorvirginica6802468101214166868setosaversicolorvirginica

Faceted Regression

Layers compose with faceting – scatter plus regression per panel:

(-> (rdatasets/reshape2-tips)
    (pj/pose :total-bill :tip {:color :sex})
    pj/lay-point
    (pj/lay-smooth {:stat :linear-model})
    (pj/facet-grid :smoker :sex))
tiptotal billsexFemaleMale51020405102040NoYesFemaleMale

Faceted Bar Chart

Five bars total across the three island panels: Adelie appears on every island, while Gentoo lives only on Biscoe and Chinstrap only on Dream. Empty species-island combinations produce no bar.

(-> (rdatasets/palmerpenguins-penguins)
    (pj/lay-bar :species {:color :species})
    (pj/facet :island))
speciesspeciesAdelieGentooChinstrapAdelieGentooChinstrap020406080100120AdelieGentooChinstrapAdelieGentooChinstrapTorgersenBiscoeDream

Comparing Multiple Columns

Pass a vector of column names to create one panel per column:

(pj/lay-histogram (rdatasets/datasets-iris) [:sepal-length :sepal-width :petal-length] {:color :species})
68024681012141623402468101214161850510152025sepal lengthsepal widthpetal lengthspeciessetosaversicolorvirginica

Labels and Faceting

pj/options works with faceted plots:

(-> (rdatasets/datasets-iris)
    (pj/lay-point :sepal-length :sepal-width {:color :species})
    (pj/facet :species)
    (pj/options {:title "Iris by Species"
                 :x-label "Sepal Length (cm)" :y-label "Sepal Width (cm)"}))
Iris by SpeciesSepal Width (cm)Sepal Length (cm)speciessetosaversicolorvirginica682.02.53.03.54.04.56868setosaversicolorvirginica

See Also

What’s Next

  • Series – reading several columns by name rather than by value
  • Troubleshooting – common issues and how to fix them
  • API Reference – complete function listing with docstrings
source: notebooks/plotje_book/faceting.clj