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.
Layeris a kind and a few settings,Chartis 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.