Skip to content
academia.sh

Lesson 21 / 22

Grammar of Graphics

The same bar chart is built in two ways: a convenience wrapper takes 9 arguments, a layered setup holds 5 layers, and both give the same values. When a reference line is added, the signature of the convenience wrapper rises from 9 to 11 and no item from the previous setup is reused; the layered setup rises from 5 to 6 layers and 5 layers stay as they are. The hidden default sits in the statistic layer: when four of the same five layers are held fixed and only the statistic is changed, the longest bar is north, east and north again — count 921, mean 24.674 m³ and total 18,613.81 m³ produce three different orderings. The position setting behaves the same way: the 6 layers stay unchanged while the number read for east/2 drops from 281 to 0.504.

Published Updated

In this lesson

  • Counts in how many items the same chart is built with a convenience wrapper and with layers, and shows that both give the same values
  • Measures the price of extending the chart separately in the two setups: the number of arguments added to the signature against the number of layers added to the list
  • Computes with three summaries which region the unwritten statistic layer moves the longest bar to
  • Measures how far the position setting moves the number read without changing the layer count
Contents

The previous lesson built the transformation from verbs and showed that each verb takes the output of the one before it. Visualization has a similar construction, but the parts do not follow one another — they stack on top of one another. The grammar of graphics treats a chart not as a single call but as the sum of mutually independent layers: data, mapping, statistic, geometry, scale, position.

This lesson’s number is the layer count: how many items are written when the same chart is built with a convenience wrapper, how many layers it holds when built with layers, and how many items are reused when the chart grows. Next to it stands the course’s second number: which default steps in when a layer is not written, and what the number read from the chart becomes.

  • AE9. The dataset is inherited from Data Collection and Preparation and is fiction: 3,199 rows, 1,329 subscribers, 10/25/40 m³ tariff tiers. The seed is written in the code.
  • AE10. The layered chart is modeled with the standard library. Layer is a kind and a few settings, Chart is a list of layers; the drawing is done with text bars, because the measured thing is not the form but the layer count. No tool or library is imitated.
  • AE11. Whether the chart type fits the scale of the axis field is not measured in this lesson; that question was measured in the Basic Charts lesson of the Analysis with Spreadsheets course. Here the type is only a geometry layer, and what is counted is the number of layers and whether they are independent of one another.
  • AE12. Two numbers stand side by side. The gain is the number of items reused while the chart is extended. The hidden default is the unwritten layer: when the statistic and position layers are not written, the chart is still drawn, but the length of the bars shows something else.

How Many Layers in a Chart

# chart.py — MODEL. First the 3,199-row fiction dataset of Data Collection and
# Preparation is regenerated from the same seed, then layered visualization is
# modeled with the standard library.
import inspect
import math

SEED, RAW, M32 = 20260218, 1400, 0xFFFFFFFF
PERIODS, TARIFF = ["2026-01", "2026-02", "2026-03"], [10, 25, 40]
REGION = [("north", 0.28, 21), ("south", 0.22, 17), ("east", 0.18, 26),
          ("west", 0.14, 14), ("central", 0.18, 23)]


def rng(t: int):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value() -> float:
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


def pick(u: float, w: list[float]) -> int:
    t = 0.0
    for i, x in enumerate(w):
        t += x
        if u < t:
            return i
    return len(w) - 1


_subscribers, _dropped = [], []
for i in range(RAW):
    r = rng(SEED + i)
    b = REGION[pick(r(), [x[1] for x in REGION])]
    record = {"subscriber_id": 10001 + i, "region": b[0], "base": b[2],
              "household_size": pick(r(), [0.06, 0.24, 0.30, 0.24, 0.11, 0.05]),
              "satisfaction": pick(r(), [0.08, 0.14, 0.27, 0.34, 0.17]) + 1}
    (_dropped if r() < 0.046 else _subscribers).append(record)
_valid = {k["subscriber_id"] for k in _subscribers}
READINGS = []
for k in _subscribers + _dropped:
    r = rng(SEED + 7000 + k["subscriber_id"])
    for d in range(pick(r(), [0.05, 0.12, 0.21, 0.62])):
        t = 0.0 if r() < 0.038 else math.floor(
            k["base"] * math.exp((r() + r() + r() - 1.5) * 0.62) * (1 - d * 0.05) * 100
            + 0.5) / 100
        if k["subscriber_id"] not in _valid:
            r()
            continue
        READINGS.append({"subscriber_id": k["subscriber_id"], "region": k["region"],
                         "period": PERIODS[d], "consumption_m3": t,
                         "satisfaction": k["satisfaction"],
                         "tier": 1 if t < TARIFF[0] else 2 if t < TARIFF[1]
                         else 3 if t < TARIFF[2] else 4})
        r()


class Layer:
    """MODEL of layered visualization: a layer is a kind and a few settings."""

    def __init__(self, kind, **setting):
        self.kind, self.setting = kind, setting

    def signature(self):
        return (self.kind, tuple(sorted(
            (k, f"{len(v)} rows" if isinstance(v, list) else str(v))
            for k, v in self.setting.items())))


SUMMARY = {"count": lambda v, a: float(len(v)),
           "mean": lambda v, a: sum(s[a] for s in v) / len(v),
           "total": lambda v, a: sum(s[a] for s in v)}


class Chart:
    def __init__(self, layers=()):
        self.layers = list(layers)

    def add(self, *k):
        return Chart(self.layers + list(k))

    def replace(self, k):
        return Chart([k if e.kind == k.kind else e for e in self.layers])

    def setting(self, kind, **missing):
        for k in self.layers:
            if k.kind == kind:
                return {**missing, **k.setting}
        return missing

    # Layers are processed in order: data is bucketed, the statistic is applied, position is adjusted.
    def value(self):
        data = self.setting("data", rows=[])["rows"]
        e = self.setting("mapping")
        summary = self.setting("statistic", summary="count")["summary"]
        bucket = {}
        for s in data:
            bucket.setdefault((s[e["x"]], s[e["color"]] if "color" in e else None), []).append(s)
        d = {a: SUMMARY[summary](v, e["y"]) for a, v in bucket.items()}
        if self.setting("position", mode="stacked")["mode"] == "proportional":
            whole = {}
            for (x, _), v in d.items():
                whole[x] = whole.get(x, 0.0) + v
            d = {a: v / whole[a[0]] for a, v in d.items()}
        return d

    def draw(self, title):
        d = self.value()
        low = self.setting("scale", low=0.0)["low"]
        high = max(d.values())
        print(f"{title} — {len(self.layers)} layers")
        for (x, r), v in sorted(d.items(), key=lambda p: -p[1]):
            n = round(24 * (v - low) / (high - low)) if high > low else 0
            print(f"  {x if r is None else f'{x}/{r}':<14}{v:9.3f}  " + "#" * max(0, n))
        for k in self.layers:
            if k.kind == "geometry" and k.setting.get("form") == "line":
                v = k.setting["value"]
                print(f"  {k.setting['label']:<14}{v:9.3f}  "
                      + "-" * max(0, round(24 * (v - low) / (high - low))))


BASE = Chart().add(
    Layer("data", rows=READINGS),
    Layer("mapping", x="region", y="consumption_m3"),
    Layer("statistic", summary="mean"),
    Layer("geometry", form="bar"),
    Layer("scale", axis="y", low=0.0))
BASE.draw("mean consumption per region (m3)")
mean consumption per region (m3) — 5 layers
  east             24.674  ########################
  central          22.396  ######################
  north            20.210  ####################
  south            16.602  ################
  west             13.778  #############

The chart was built from five layers, and each layer answers a single question. The data layer says which rows will be drawn. The mapping says which field goes to which axis. The statistic layer says how the number to be drawn is derived from the raw rows — here, the mean of each region. The geometry layer says in what form that number is drawn, and the scale layer says the axis.

What matters about this separation is that the layers are independent of one another. Changing the geometry from bar to point does not touch the other four layers; changing the statistic from mean to count does not change the geometry. A chart is not a fixed list of options but a sentence built from combinable parts.

Independence does not mean the layers are unordered. The value method does the work in a fixed order: first the data is separated into buckets according to the mapping, then the statistic is applied to each bucket, and the position setting is applied last. In whatever order the layers are written on the page, this order of processing does not change; the only thing that changes is which layers are in the list. In the verb chain the order was the writing itself; here the order is a rule of the grammar and is not read from the writing.

Adding a Layer, Widening a Call

The same chart can also be built with a convenience wrapper: all the decisions are gathered in a single signature, and the caller only fills in the arguments. The difference between the two shows up when the chart grows.

def ready_bar(rows, x, y, summary, color=None, position="stacked", low=0.0, high=None,
              title=""):
    """MODEL of the convenience wrapper: all decisions are gathered in a single signature."""
    return Chart().add(Layer("data", rows=rows),
                       Layer("mapping", x=x, y=y),
                       Layer("statistic", summary=summary),
                       Layer("geometry", form="bar"),
                       Layer("scale", axis="y", low=low))


# To add a reference line, two more arguments have to enter the signature.
def ready_bar_with_reference(rows, x, y, summary, color=None, position="stacked", low=0.0,
                             high=None, title="", reference=None, reference_label=""):
    g = ready_bar(rows, x, y, summary, color, position, low, high, title)
    return g if reference is None else g.add(
        Layer("geometry", form="line", label=reference_label, value=reference))


OVERALL = sum(s["consumption_m3"] for s in READINGS) / len(READINGS)
WITH_REFERENCE = BASE.add(Layer("geometry", form="line", label="overall mean",
                                value=round(OVERALL, 3)))
old = [k.signature() for k in BASE.layers]
kept = sum(1 for k in WITH_REFERENCE.layers if k.signature() in old)


def arg_count(f):
    return len(inspect.signature(f).parameters)


def align(w):
    return lambda h: "".join(str(v).ljust(w[0]) if j == 0 else str(v).rjust(w[j])
                             for j, v in enumerate(h))


same = ready_bar(READINGS, "region", "consumption_m3", "mean").value() == BASE.value()
print(f"convenience wrapper and layered setup give the same values: "
      f"{'yes' if same else 'no'}")
y = align([28, 16, 22, 22])
print("\n" + y(["setup", "base chart", "reference added", "reused"]))
print(y(["convenience wrapper (args)", arg_count(ready_bar), arg_count(ready_bar_with_reference), 0]))
print(y(["layer list", len(BASE.layers), len(WITH_REFERENCE.layers), kept]))
print()
WITH_REFERENCE.draw("same chart, reference line added")
convenience wrapper and layered setup give the same values: yes

setup                             base chart       reference added                reused
convenience wrapper (args)                 9                    11                     0
layer list                                 5                     6                     5

same chart, reference line added — 6 layers
  east             24.674  ########################
  central          22.396  ######################
  north            20.210  ####################
  south            16.602  ################
  west             13.778  #############
  overall mean     19.713  -------------------

The first line is the anchor: the two setups give the same values, so what is measured is not the result but the structure. The convenience wrapper builds the base chart with 9 arguments, the layer list with 5 layers. As a count, the convenience wrapper does not look like it holds fewer items, but the real distinction is in the second column.

When a line showing the overall mean is added to the chart, the signature of the convenience wrapper rises from 9 to 11. This is a new signature seen by every place that uses that call, and the number of items reused from the previous setup is zero: the call is a whole, and a part of it cannot be carried over. In the layered setup the list rises from 5 to 6 and 5 layers stay as they are; the only thing added is one geometry layer. The extension is written into the list here, into the signature there.

There is also a reverse side to this, and it has to be written down. The convenience wrapper gives a frequently used chart in a single line and does not require stacking five layers by hand; in the layered setup, everyone who builds the same chart rewrites the same five layers. The measure is not a ranking of superiority: the convenience wrapper makes repetition cheaper, the layered setup makes extension cheaper.

If the Statistic Layer Is Not Written

The price of the independence of layers is that when a layer is not written, the chart is drawn without an error. The model puts a default in place of the unwritten layer, and the bars are still visible.

y2 = align([14, 12, 12, 12, 12, 12, 16])
REGIONS = ("north", "east", "central", "south", "west")
print(y2(["statistic"] + list(REGIONS) + ["longest bar"]))
longest = []
for summary in ("count", "mean", "total"):
    g = BASE.replace(Layer("statistic", summary=summary))
    d = {a[0]: v for a, v in g.value().items()}
    longest.append(max(d.items(), key=lambda p: p[1])[0])
    print(y2([summary] + [f"{d[b]:.3f}" for b in REGIONS] + [longest[-1]]))
print(f"\nof the same {len(BASE.layers)} layers four are identical; only the statistic layer "
      f"changed and the longest bar became {' -> '.join(longest)}")

COLORED = Chart([Layer("data", rows=READINGS),
                 Layer("mapping", x="region", y="consumption_m3", color="tier"),
                 Layer("statistic", summary="count"),
                 Layer("geometry", form="bar"),
                 Layer("scale", axis="y", low=0.0)])
y3 = align([18, 12, 12, 12, 12])
print("\n" + y3(["position setting", "east/1", "east/2", "east/3", "east/4"]))
second = []
for mode in ("stacked", "proportional"):
    d = COLORED.add(Layer("position", mode=mode)).value()
    second.append(d[("east", 2)])
    print(y3([mode] + [f"{d[('east', b)]:.3f}" for b in (1, 2, 3, 4)]))
print(f"\nlayer count {len(COLORED.layers) + 1} is the same in both settings; a single setting "
      f"of a single layer changed and the number read for east/2 became {second[1]:.3f} "
      f"instead of {second[0]:.0f}")
statistic            north        east     central       south        west     longest bar
count              921.000     557.000     600.000     645.000     476.000           north
mean                20.210      24.674      22.396      16.602      13.778            east
total            18613.810   13743.340   13437.310   10708.020    6558.150           north

of the same 5 layers four are identical; only the statistic layer changed and the longest bar became north -> east -> north

position setting        east/1      east/2      east/3      east/4
stacked                 21.000     281.000     225.000      30.000
proportional             0.038       0.504       0.404       0.054

layer count 6 is the same in both settings; a single setting of a single layer changed and the number read for east/2 became 0.504 instead of 281

The three rows use the same data, the same mapping, the same geometry and the same scale. The only thing that changes is the statistic layer, and what the chart says changes three times. When count is chosen, the longest bar is in the north, because the north has 921 readings. When mean is chosen, the longest bar moves to the east, because the east, with 557 readings, is the less crowded region but has the highest mean consumption. When total is chosen, the bar goes back to the north. All three are correct and all three are the same bar chart; the only thing that says which one is drawn is the statistic layer, written or unwritten.

The position setting shows the same behavior once more. The six layers are exactly the same in both runs; the only difference is the setting of the position layer. In the stacked setting, the second tariff tier of the east region has 281 readings, and the length of the bar is a number. In the proportional setting the same place becomes 0.504, and the length of the bar is a share. A reader looks at the second chart and says “in the east the second tier is close to half” and is right; looks at the first and says “in the east the second tier has 281 readings” and is right too. How many readings there are cannot be read from the second chart, and what the share is cannot be read from the first.

The course’s rule applies to the chart as well: an abstraction shortens the work as much as it hides decisions. What the layered setup provides is that the decisions are named — the statistic is a layer, the position is a layer, and someone looking at the chart’s list can see which one is written. An unwritten layer, on the other hand, falls to a default and the chart is still drawn; the person looking at the page is left with no record saying what the bar measures.

Summary

  • The same bar chart is built with 9 arguments by the convenience wrapper and with 5 layers by the layered setup, and both give the same values.
  • When a reference line is added, the signature of the convenience wrapper rises from 9 to 11 and the number of reused items is 0; the layer list rises from 5 to 6 and 5 layers stay as they are.
  • The convenience wrapper makes repetition cheaper, the layered setup makes extension cheaper; the measure is not a ranking of superiority.
  • When the statistic layer changes, four layers of the chart stay fixed but the longest bar becomes north, east and north again: count 921, mean 24.674 m³, total 18,613.81 m³.
  • The position setting changes the number read without changing the layer count: the second tier of the east region reads 281 in the stacked setting and 0.504 in the proportional setting.

Next Step

Three lessons measured three approaches: in how many statements the same analysis is expressed, with how many verbs and how many intermediate variables the transformation is written, with how many layers the chart is built. In all of them the measure was the form of the expression, and in none of them was one environment said to be better than another. What remains is the choice itself: what does a team look at when it decides which environment to work in. The next lesson ties this to a criterion — the skill the team already has, the coverage of the ready-made parts around it and the portability of the work to production — and compares the three environments by number on three criteria, writing down in which criterion the choice changes. The course closes there and counts the gain and the hidden default of the twenty-two lessons in a single table.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close