D3 · The Going-Rate Atlas

Capstone dossier · what every dish-type costs in Amsterdam, with honest ranges

Author

Dei Martinez Elurbe

Published

July 2, 2026

Dossier, not the essay. Backstage layer for technical readers; the capstone essay is the general-audience artifact. Corpus: Snapshot · June 2026, ~900 priced venues, roughly 4 in 10 of Amsterdam’s ~2,028 restaurants.

The verdict

Amsterdam has a going rate for everything, and now it is written down. The Dish Atlas prices 200 dish-type cells (147 at full confidence, 53 stabilised by pooling), covering 96% of 32,651 clean dishes: each cell gets a typical price (the empirical median) and a 90% range (where 90% of that dish-type’s prices actually fall). Carpaccio as a starter: €15, range €11.50 to €23. Friet: €5.80. Bitterballen: €7.20. A dorade main: €24.50. No venue is named anywhere in the atlas; it prices dish-types, never restaurants.

Two design decisions carry the credibility. Typical = the median, not the mean, because on skewed price distributions the model’s own pooled mean misleads by 27 to 38% (the cheese-board cell reads €10.50 by geometric mean and €14.50 by median; the median is what a diner actually encounters). And the range is the partially-pooled quantity: big cells show their own percentiles, thin cells borrow spread from their parent cells, so a 20-dish cell cannot fake a precise range it has not earned.

Show the code
suppressPackageStartupMessages({library(dplyr); library(ggplot2)})

atlas <- readr::read_csv("../../data/processed/dish_atlas.csv", show_col_types = FALSE)

picks <- c("side · vegan · none · other", "snack · meat · fried · other",
           "sweet · any · baked · other", "starter · meat · raw · plate",
           "main · meat · none · sandwich", "main · vegetarian · baked · pizza",
           "main · meat · baked · pizza", "main · meat · none · pasta",
           "main · meat · stew · curry", "main · meat · none · plate",
           "main · fish · none · plate", "main · meat · grilled · plate")

sel <- atlas |> filter(cell %in% picks) |>
  mutate(label = sprintf("%s  ·  %s", exemplar, sub(" · none", "", cell)))

ggplot(sel, aes(x = typical_eur, y = reorder(label, typical_eur))) +
  geom_linerange(aes(xmin = ppi_lo_eur, xmax = ppi_hi_eur),
                 colour = "#b5623f", alpha = .45, linewidth = 1.6) +
  geom_point(colour = "#b5623f", size = 2.6) +
  geom_text(aes(label = sprintf("€%.2f", typical_eur)), vjust = -0.9, size = 3) +
  labs(x = "Typical price (median) with 90% range (€)", y = NULL) +
  theme_minimal()
Dot and interval chart of twelve dish types sorted by typical price, from friet at 5.80 euro to bife de chorizo at 25.50 euro, each with its 90 percent range.
Figure 1: The going rates: typical price (dot) and 90% range (line) for twelve recognizable cells. Each label is the cell’s exemplar, the most common cleaned dish name inside it; it stands for the whole cell, not for one dish.

Why the obvious approach lies, twice

The first lie is averaging across products. “The average dish price in Amsterdam” mixes a €2.50 side with a €49 Wagyu; it measures menu composition, nothing else. The coordinate system (dossier D1) fixes that: prices are only ever summarised within a cell, where dishes are the same kind of thing.

The second lie is subtler: the mean lies even within a cell. Dish prices are right-skewed (a long tail of expensive versions), and the model is fitted on log-prices, whose natural “average” back-transforms to the geometric mean. On skewed cells that number drifts far from what a diner actually meets. The skew check that caught this compared the model’s pooled mean against the empirical median across all confident cells: gaps up to 27 to 38%. Verdict: the model keeps the mean internally for validation, and the atlas displays the median. The variance components confirm the median is reliable even at 15 dishes per cell; what is unreliable in small cells is the tails, and that is exactly where pooling is aimed.

The model: pooling for the tails

The fit is a nested random-effects model on log price:

\[ \log(\text{price}) \sim \text{role} + (1 \mid \text{role:protein}) + (1 \mid \text{role:protein:method}) + (1 \mid \text{cell}) + (1 \mid \text{venue}) \]

The nesting is the point: a cell sits inside a role:protein:method family, which sits inside a role:protein family. A thin cell (say, 18 lamb stews) does not have to estimate its price spread alone; it borrows from its parents in proportion to how little data it has. The displayed 90% range blends the cell’s own 5th-to-95th percentile span with the model’s pooled predictive spread, weighted by cell size (weight = n/(n+40)). Big cells are effectively empirical; thin cells are honestly wider than their own data would naively suggest. The venue random effect keeps one expensive restaurant’s menu from imprinting on a cell.

Escalating to a fully Bayesian fit (brms) was considered and declined, on the light-first rule: with 31,000+ dishes the residual spread is pinned, the median would not move, and the extra machinery would change nothing a reader can see. The lme4 empirical-Bayes fit runs in four seconds and reproduces to the cent.

What it found, beyond the lookup

Sure bets and gambles. Range width is information. The most price-predictable dishes in the city are the cuisine-format ones: pizza (a diavola’s 90% range spans only ±35% of its €17.50 typical) and curries. The widest are the catch-all and sharing formats: a borrelplank runs €9.80 to €49.70 around an €18.50 typical, and a sharing-size kipsaté anywhere from €12.70 to €60. The practical reading for a diner: you can trust a pizza price sight-unseen; never assume a sharing platter.

Why widths differ this much: the quality ceiling. The model cannot see undisclosed portion size or ingredient quality, and dish names differ enormously in how much they pin those down. A borrelplank has no quality ceiling: the same menu line covers kaasblokjes with supermarket worst and an ibérico charcuterie board, and the price honestly spans that distance. A pizza, even loaded with the most premium ingredients, still has a pizza ceiling: the format bounds the plate, and the city carries a reference price for it. Range width is that invisibility made visible. Wide cells are cells where the name does not pin the product; tight cells are cells where it does. (Seen through dossier D4’s decomposition: the ranges are its non-dish 41%, venue character plus item noise, displayed cell by cell; D4’s “ladder and the rungs” section shows the two dossiers are one set of numbers.)

Show the code
ww <- atlas |> filter(tier == "confident", n >= 100) |>
  mutate(relw = (ppi_hi_eur - ppi_lo_eur) / typical_eur,
         label = sprintf("%s  ·  %s", exemplar, sub(" · none", "", cell)))
wide   <- ww |> arrange(desc(relw)) |> head(6) |> mutate(group = "widest (gambles)")
narrow <- ww |> arrange(relw)       |> head(6) |> mutate(group = "narrowest (sure bets)")

ggplot(bind_rows(narrow, wide),
       aes(x = relw, y = reorder(label, relw), fill = group)) +
  geom_col(width = .7) +
  scale_fill_manual(values = c("widest (gambles)" = "#b5623f",
                               "narrowest (sure bets)" = "#7a9e7e"), name = NULL) +
  labs(x = "90% range width ÷ typical price", y = NULL) +
  theme_minimal() + theme(legend.position = "top")
Horizontal bar chart of relative range width for six narrow cells (pizza and curry types, around 0.6 to 0.7) and six wide cells (sharing platters and snack starters, 1.7 to 2.2).
Figure 2: Sure bets vs gambles: the narrowest and widest 90% ranges relative to the typical price, among confident cells with at least 100 dishes. Cuisine-format dishes price tightly; sharing and catch-all formats do not.

The atlas is a lookup table. The ten biggest cells, as they ship:

Show the code
atlas |> arrange(desc(n)) |> head(10) |>
  transmute(cell, exemplar, n, venues,
            typical = sprintf("€%.2f", typical_eur),
            `90% range` = sprintf("€%.1f – €%.1f", ppi_lo_eur, ppi_hi_eur)) |>
  knitr::kable()
Table 1: The ten biggest atlas cells. Typical = empirical median; the range is the 90% partially-pooled interval.
cell exemplar n venues typical 90% range
main · meat · none · plate kalfslever 2097 449 €21.70 €11.0 – €37.1
main · meat · none · sandwich carpaccio 1998 394 €12.50 €5.5 – €21.6
sweet · any · none · other affogato 1916 518 €7.70 €3.0 – €13.7
main · fish · none · plate dorade 969 339 €24.50 €12.0 – €38.7
side · vegan · none · other friet 937 421 €5.80 €2.5 – €9.6
main · meat · grilled · plate bife de chorizo gr 912 309 €25.50 €14.0 – €48.3
main · meat · baked · pizza diavola 905 112 €17.50 €12.3 – €22.4
sweet · any · baked · other cheesecake 751 324 €6.00 €3.0 – €12.9
main · vegetarian · baked · pizza margherita 719 116 €16.00 €9.9 – €22.3
main · vegetarian · none · sandwich mozzarella di bufala 650 276 €9.50 €4.9 – €18.0

The stress-test battery

The threat The test The verdict
The displayed center misleads on skewed cells geomean vs median across all confident cells gaps up to 27–38% (cheese board €10.50 vs €14.50): median displayed, geomean demoted to a validation column
Package lines masquerade as dish-types cell-contents audit, triggered by a band-width sort on the public atlas page four package coordinates (444 items: promo bundles, prix-fixe lines, high teas, desserts-for-two) quarantined whole to dish_atlas_excluded.csv; survivors’ typicals unmoved, range endpoints shift ≤ €0.30
The pooling constant is arbitrary audit re-ran with alternative K_W values ranges move 3–4%: not sensitive
The pipeline drifted from the canonical corpus adversarial data-layer audit, reproduced from scratch lineage faithful: the atlas dish table is a bit-identical subset of canonical items.csv (set-difference zero both ways)
Ranges are fantasy hand-validation of 10 cells against real menu dishes exemplars faithful; ranges correctly exclude real extremes (a €49 Wagyu, a €78.50 T-bone, a €67.50 Dover sole all sit outside their cells’ 90% bands, as 5%-tails should)
Dessert prices biased by a silent filter audit reproduction 985 sweets (34%) were being dropped, skewing sweet cells ~15–20% low; fixed, desserts recovered (the cheesecake cell grew 436 → 751 dishes)
Per-piece prices contaminate cells mechanism-matched hygiene guard (D1) main · fish · raw · sushi median €4.40 → €16.50
A heavier model would change the answer lme4 vs brms escalation decision declined on light-first grounds: 31k dishes pin the spread, the median cannot move; lme4 reproduces to the cent in 4 seconds

What this is, and what it isn’t

  • No venue is named, structurally. The atlas aggregates over venues by construction; the per-venue claims that sank the earlier Value Atlas cannot be expressed in it.
  • The exemplar is a label, not a promise. “Butter chicken” is the most common name inside the meat-curry cell, but actual butter chickens cluster in the upper part of that cell’s range. Read “meat curry, e.g. butter chicken”, never “butter chicken costs €20.80”. (This framing is a known presentation task carried forward from the audit.)
  • Within-cell quality and portion are invisible. A €12 and a €19 carpaccio may differ in provenance and grams; the atlas sees neither. It prices the type, honestly wide.
  • Menu price, not paid price. No service, no specials, no portion inflation observed.
  • A snapshot. One city, one month, ~4 in 10 restaurants. The going rates are solid within that scope and dated by construction.

Lineage

  • Method + brief: docs/m5_typical_menus.md (§4 method, §6 evidence base); audit record docs/m5_data_audit_findings.md.
  • Code: R/50_dish_atlas_hygiene.R (cells + hygiene), R/51_dish_atlas_fit.R (the nested lme4 fit, the median decision, the K_W=40 blend).
  • Data: data/processed/dish_atlas.csv (200 cells), dish_atlas_dishes.csv (32,651 dishes), dish_atlas_excluded.csv (the four quarantined package coordinates, with reasons).
  • Journal trail: 2026-06-30_001 (the atlas crystallises), 2026-06-30_002 (the fit + the skew catch + 10-cell hand-validation), 2026-06-30_005/_006 (audit + fixes), 2026-07-07_013 (the package-cell curation).
  • Sources: restaurant websites (menus), OpenStreetMap (venue universe). All open data. Snapshot · June 2026.