D4 · What Moves the Price

Capstone dossier · the variance decomposition, the context multipliers, and the standard dinner

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

Take any dish price in Amsterdam and ask what put it there. The answer, decomposed over 32,651 dishes: what the dish is explains 59% of the variation, which venue sells it explains 17%, and the remaining 24% is item-level noise within venues. The thesis of the whole project sits in that split: what you pay is what you order, not where you sit.

The venue’s 17% breaks down further, and mostly into mist: observable context (venue type, menu styling, location) explains only about 8 points of it; the rest is idiosyncrasy. The observable multipliers themselves are modest and precisely estimated: a café serves the same dish for −11%, a specialist for −6%, menu styling adds a little in both of its registers (florid wordiness +2.7% per step; the terse fine-dining register +7.1%, Decision #42), and the periphery runs −10 to −16% below Centrum. And the punchline check: price a fixed “standard dinner” (burrata, pasta, cheesecake) across the city and it costs €35 to €38 everywhere, with no area statistically distinguishable from Centrum (the meatier carpaccio-steak-tiramisu version: €42 to €45, same shape). The upscale experience is real, but it reaches your bill through which dishes are on the menu, not through a markup on the same plate.

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

shares <- readr::read_csv("../../data/processed/dish_atlas_variance_shares.csv",
                          show_col_types = FALSE) |>
  mutate(source = factor(c("what the dish is", "which venue", "item noise"),
                         levels = rev(c("what the dish is", "which venue", "item noise"))))

ggplot(shares, aes(x = pct, y = "share", fill = source)) +
  geom_col(width = .55) +
  geom_text(aes(label = sprintf("%s\n%.0f%%", source, pct)),
            position = position_stack(vjust = .5), size = 3.2, lineheight = .95) +
  scale_fill_manual(values = c("item noise" = "grey80", "which venue" = "#d9a08a",
                               "what the dish is" = "#b5623f"), guide = "none") +
  theme_void()
Single horizontal stacked bar showing dish type 59 percent, venue 17 percent, item noise 24 percent.
Figure 1: The variance decomposition: shares of log-price variation across 32,651 dishes. The dish-type cell carries 59%; the venue, net of its dish mix, 17%.

Why the obvious comparison lies

“Restaurant X is expensive” usually means “restaurant X’s average dish price is high”, and that number conflates two things: charging more for the same dishes, and serving different dishes. A steakhouse’s €28 average is mostly rib-eyes being rib-eyes. Separating those channels requires knowing what each dish is, which is what the coordinate system (dossier D1) provides. With every dish in a cell, one mixed-effects model splits the price into the three components above.

Inside the venue’s 17%: mostly mist

Each venue’s price level (its random effect from the model above: how much pricier it is than its dish mix predicts) can be regressed on everything observable about it. The result is humbling: venue type plus menu styling (both text registers) explains roughly 5 points, location roughly 3, everything observable together about 8 of the 17. The rest is venue idiosyncrasy: the specific ambition, rent, and pricing habits of one restaurant, invisible to any of our covariates.

This number has a structural consequence worth stating plainly, because it killed a product. A per-venue “deal or rip-off” verdict has at most 17 points of signal to work with, of which 93% is unexplained; any honest per-venue claim is therefore modest and caveat-heavy by construction. That is the deep reason the earlier per-venue Value Atlas was retired and this project prices dish-types instead (the full story is in structural_walls_for_fable.md).

The ladder and the rungs: seeing this split inside the atlas

Read next to the going-rate atlas (D3), this dossier can look self-contradictory: the thesis says the dish decides, yet the atlas itself prices a carpaccio anywhere from €11.50 to €23. There is no contradiction; the two dossiers display the same variance from two sides, and the split is visible in the atlas table alone. Recompute it without any model: total log-price variance across the 32,651 dishes is 0.369; the average variance within a cell is 0.144. That is a 61/39 split between “which cell” and “everything else”, the crude version of the model’s 59/41 (the model partials out venue overlap properly). D3’s ranges are the 38%, displayed cell by cell.

Show the code
suppressPackageStartupMessages({library(dplyr); library(ggplot2)})
atlas <- readr::read_csv("../../data/processed/dish_atlas.csv", show_col_types = FALSE) |>
  arrange(typical_eur) |> mutate(rank = row_number())
marks <- atlas |> filter(cell %in% c("side · vegan · none · other",
                                     "starter · meat · raw · plate",
                                     "main · mixed · none · sharing")) |>
  mutate(mark = case_when(
    cell == "side · vegan · none · other"   ~ "friet €5.80",
    cell == "starter · meat · raw · plate"  ~ "carpaccio €15",
    TRUE                                    ~ "sharing board €35.50"))

ggplot(atlas, aes(rank, typical_eur)) +
  geom_linerange(aes(ymin = ppi_lo_eur, ymax = ppi_hi_eur),
                 colour = "#d9a08a", alpha = .4) +
  geom_point(colour = "#b5623f", size = .9) +
  geom_text(data = marks, aes(label = mark), vjust = -1.6, size = 3, colour = "grey20") +
  scale_y_log10(breaks = c(3, 5, 10, 20, 40)) +
  labs(x = "cells, ordered by typical price", y = "€ (log scale)") +
  theme_minimal()
200 cells ordered by typical price on a log scale, dots rising from about 4.50 to 32.50 euro with light vertical range bars around each; carpaccio, friet, and the sharing-board cells annotated.
Figure 2: The ladder and the rungs: all 200 atlas cells ordered by typical price (dots), each with its 90% range (light bars), log scale. The ladder spans €4.50 to €32.50 (7×); a typical rung spans a factor ~2 to 3. Choosing the rung is most of the price; the rung’s own width is venue character and item noise, not geography.

The walk a reader should take. Predict the price of an unknown Amsterdam dish:

  • Knowing nothing: the median dish is €13, and your 90% guessing band is €4.00 to €28.50, a 7× spread.
  • Told it is a carpaccio starter: your guess is €15 and the band collapses to €11.50 to €23, a 2× spread. That collapse is the 59%. Nothing shrinks your uncertainty like knowing what the dish is.
  • Told additionally it is at a café, in Noord: your guess drops to about €11.90, and the band shifts down with it, to roughly €9 to €18. But it barely narrows. Context moves the band; it does not tighten it.

And the range is not geography. The carpaccio band survives essentially intact inside every single borough:

Show the code
d  <- readr::read_csv("../../data/processed/dish_atlas_dishes.csv", show_col_types = FALSE)
ui <- readr::read_csv("../../data/processed/atlas_ui.csv", show_col_types = FALSE) |>
  select(osm_id, stadsdeel)
d |> filter(cell == "starter · meat · raw · plate") |> inner_join(ui, by = "osm_id") |>
  group_by(stadsdeel) |> filter(n() >= 15) |>
  summarise(dishes = n(),
            `5th pct` = sprintf("€%.2f", quantile(price_per_unit, .05)),
            median = sprintf("€%.2f", median(price_per_unit)),
            `95th pct` = sprintf("€%.2f", quantile(price_per_unit, .95))) |>
  knitr::kable()
Table 1: The carpaccio-starter cell within individual boroughs (those with at least 15 such dishes). The borough medians sit within about 5% of each other; the spread lives complete inside each one.
stadsdeel dishes 5th pct median 95th pct
Centrum 68 €12.18 €15.50 €20.44
Oost 22 €13.76 €15.00 €25.00
West 26 €11.75 €14.72 €21.73
Zuid 55 €12.95 €15.50 €22.00

The arithmetic makes the thesis concrete. Moving the same €15 carpaccio to Noord saves about €1.60. Ordering the bife de chorizo instead costs about €10.50 more. And the reason location is ~3% of city-wide variation even though Noord reads −10.5%: the bulk of the city’s venues sit in boroughs within a few percent of Centrum (Zuid −1.1%, Oost −3.3%, West −4.9%), the double-digit discounts belong to boroughs holding few venues, and a ±10% shift is small against a 7× total spread.

What a rung’s width is actually made of: venue character (mostly invisible: ambition, portions, ingredient quality; D3 documents why some dish names bound this and others do not) and item noise (which carpaccio: the standard one or the Wagyu variant on the same menu). Neither is where you sit.

The context multipliers

Adding the observable context as fixed effects (venue type, fanciness, area, with the cell and venue random effects retained) prices each modifier for the same dish:

Show the code
ctx <- readr::read_csv("../../data/processed/dish_atlas_context_effects.csv",
                       show_col_types = FALSE) |>
  mutate(label = case_when(
    block == "venue_type" ~ paste0("café / specialist: ", level),
    block == "fineness"   ~ "fanciness, florid register (per +1 SD)",
    block == "register"   ~ "fanciness, terse register (flag)",
    block == "area"       ~ paste0("area: ", level, " (vs Centrum)")),
    label = sub("café / specialist: cafe", "café (vs all-rounder)", label),
    label = sub("café / specialist: specialist", "specialist (vs all-rounder)", label))

ggplot(ctx, aes(x = pct, y = reorder(label, pct))) +
  geom_vline(xintercept = 0, linetype = "dashed", colour = "grey50") +
  geom_pointrange(aes(xmin = ci_lo, xmax = ci_hi), colour = "#b5623f", linewidth = .6) +
  geom_text(aes(label = sprintf("%+.1f%%", pct)), vjust = -0.8, size = 3) +
  labs(x = "Effect on the price of the same dish (%), 95% CI", y = NULL) +
  theme_minimal()
Forest plot: cafe minus 11 percent, specialist minus 5.4, florid fanciness plus 2.7 per SD, terse register plus 7.1, and area effects from Zuid near zero down to Weesp minus 19.7 percent, each with confidence intervals.
Figure 3: The context multipliers with 95% confidence intervals: what each factor adds to the price of the same dish. Reference categories: all-rounder venue, Centrum. Fanciness enters in its two text registers (Decision #42): florid wordiness per +1 SD (standard deviation), and the terse ingredient-list register as a flag.

Worked arithmetic on one dish, using the atlas typical for a carpaccio starter (€15):

  • the same carpaccio at a café: about €13.30;
  • at a venue 1.5 SD wordier than average: about €15.60;
  • at a terse-register venue (the “word · word · word” menu): about €16.10;
  • in Noord: about €13.40.

The multipliers are real, and they are small next to the choice between dishes: switching from the carpaccio to the bife de chorizo (€25.50) moves the bill more than any context multiplier in the city.

Match the controls to the question

One dead-end from the build deserves its paragraph, because it is the methodological rule of this dossier. An early specification estimated the burger’s cuisine premium net of its sandwich form and produced burger +42%: an artifact, because sandwich-form is part of what a burger is, and controlling for it leaves only the weird residual burgers. The banked rule: for a fair prediction, include all axes; for “what is X worth”, never control away the attributes that constitute X. Every effect in this dossier follows it (the café effect does not control for “being a café’s kind of menu”; the fancy tax does not control for fancy dishes).

The standard dinner: homogenisation, measured

If location markup were a real force, the most comparable dishes should show it most clearly. So build a Standard Basket: nine universal dish families every neighbourhood serves (soup, carpaccio/tartare, burrata, salad, burger, steak, fish of the day, pasta, tiramisu/cheesecake), keep venues carrying at least two of them, and fit one model where the basket-item effect absorbs which dishes and the area effect prices the location, apples-to-apples. Re-run on the audited canonical corpus for this dossier: 4,384 standard dishes across 528 venues.

Show the code
din <- readr::read_csv("_data/basket_dinner.csv", show_col_types = FALSE) |>
  filter(bundle == "burrata + pasta + cheesecake")

ggplot(din, aes(x = dinner, y = reorder(stadsdeel, dinner))) +
  geom_point(colour = "#b5623f", size = 3) +
  geom_text(aes(label = sprintf("€%.0f  (n=%d)", dinner, n_venues)),
            vjust = -0.9, size = 3) +
  scale_x_continuous(limits = c(33, 40)) +
  labs(x = "Standard dinner: burrata + pasta + cheesecake (€)", y = NULL) +
  theme_minimal()
Dot plot of the standard dinner price for eight boroughs, all between 34.6 and 37.8 euro, with venue counts labelled.
Figure 4: The standard dinner (burrata + pasta + cheesecake, model-predicted at average fanciness; the dessert term is the tiramisu/cheesecake family, one coefficient) by stadsdeel: €35 to €38 across the whole city, a 9% spread. No area’s basket-price level is statistically distinguishable from Centrum (all 95% CIs straddle zero; Weesp has 6 venues and is indicative only). The meatier carpaccio-steak-tiramisu bundle gives the same picture at €42 to €45; both are persisted in the data file.

Two honest readings, side by side. On the full corpus (every dish, every venue), the periphery discount is real and significant: Zuidoost −15.2%, Nieuw-West −10.1% against Centrum. On the standard basket (the universal classics, at the generalist venues that serve them), the point estimates run the same direction but small, and every area’s interval straddles zero. Both are true, and together they are the finding: the differences that exist live in the long tail of distinctive dishes and venues; for the everyman dinner, the city’s prices have converged. The earlier directional figure (€41 to €46 on the pre-audit corpus, carpaccio-steak-tiramisu bundle) survives the canonical re-run essentially unchanged (€42 to €45); the featured bundle switched to burrata-pasta-cheesecake (€35 to €38, same 9% spread) on 2026-07-02, an editorial choice, not a model change.

Two cross-checks against the rest of the project

  • The tourist tax fits inside this picture. D5’s +4.2% per SD of tourism exposure is a location effect, and location is ~3% of venue variation here; the two dossiers measure the same small-but-real channel with different instruments, and the area ranking (Centrum high, Zuidoost/periphery low) is near-identical across the M3 premium map and this model.
  • The café effect resolves an old wound. M3’s strata had no café category (its “mortal wound” for per-venue claims); this decomposition finally prices café-ness directly, at −11%, with the dish held constant.

The stress-test battery

The threat The test The verdict
The decomposition is a modelling artifact adversarial data-layer audit, reproduced from scratch shares reproduced (59/17/24), fit non-singular, M3-coherent
The audit fixes moved the answer pre-fix vs post-fix comparison dish-type share 60% → 59%, café −10% → −11%: stable
Package lines inflated the corpus the 2026-07-07 package-cell curation (444 items removed, D3 stress table), full re-run shares 59.0/16.9/24.1 → 59.1/16.9/24.0, café −11.4% → −11.0%, florid +2.8 → +2.7, terse +7.4 → +7.1: stable; only small-n Weesp moved materially (−16.2 → −19.7, 6 venues, indicative)
Over-controlling manufactures premiums the burger +42% dead-end caught, banked as the match-controls-to-question rule
The homogenisation number was stale (“directional, not re-run”) canonical Standard Basket re-run (this dossier, _prep/basket_rerun.R) veg trio €35–38 / classic trio €42–45, both 9% spread, all area CIs straddle zero: the directional claim survives on the audited corpus
Fanciness is circular (fancy = expensive) both registers measured from menu text, never price proxies, disclosed as such; florid +2.7%/SD [0.7, 4.8], terse flag +7.1% [0.9, 13.6]
The wordiness proxy misses terse fine dining leader’s catch → detector + pre-committed adoption probes (D6 has the full record) confirmed and adopted (Decision #42): the one-register estimate was attenuated; correction ran the conservative direction, other coefficients drifted < 1pp
Location effect contradicts the tourist tax rank comparison vs the M3 premium artifact near-identical area ordering; consistent magnitudes

What this is, and what it isn’t

  • Fanciness is proxied, in two registers. Menu wordiness (“hand-dived Zeeland scallops, beurre blanc”) and the terse ingredient-list register (“krab · lavas · courgette”) both mark upscale positioning; neither is a Michelin scale. Their effects are small and should be read as directional.
  • Area effects are descriptive. Stadsdeel enters as a label, not an explanation; the affluence interpretation of the centre-periphery gradient is deliberately deferred (Ethics §1) and is not claimed here.
  • Observational, not causal. “A café charges −11% for the same dish” is a conditional description of this snapshot, not a lever anyone can pull.
  • The venue mist is honest ignorance. The unexplained 93% of venue variation includes everything we cannot see: portions, quality, rent, ambition. It bounds what any per-venue product built on this corpus could ever claim.

Lineage

  • Method + brief: docs/m5_typical_menus.md (§4a layered effects, §4f the controls rule); audit record docs/m5_data_audit_findings.md.
  • Code: R/52_dish_atlas_context.R (decomposition + two-register multipliers + fancy-tax table); _prep/basket_rerun.R (the canonical Standard Basket re-run); _prep/terse_adoption_probes.R (the register-adoption gate, record in D6).
  • Data: dish_atlas_variance_shares.csv, dish_atlas_context_effects.csv, _data/basket_area_index.csv, _data/basket_dinner.csv.
  • Journal trail: 2026-06-29_001 (the pivot: type+fineness vs location), 2026-06-30_001 (layered-effects design lock + the burger dead-end), 2026-06-30_004 (promotion), 2026-06-30_005/_006 (audit + fixes).
  • Sources: restaurant websites (menus), OpenStreetMap (venue universe). All open data. Snapshot · June 2026.