library(tidyverse)
dat <- readr::read_csv("booking_assignment.csv")
glimpse(dat)Assignment I — Booking Cancellations
Overview
A mid-range hotel chain wants to know whether upselling guests to a meal package at booking time affects the rate at which those bookings are later cancelled. Marketing argues the upsell increases commitment and should reduce cancellations; revenue management worries the higher all-in price causes sticker shock and raises cancellations. Both effects are plausible. The chain has handed you a snapshot of recent reservations and asked: what is the causal effect of the meal upsell on the cancellation rate?
This assignment walks you through the full graphical-causal-inference pipeline on this dataset:
- Develop and justify a Directed Acyclic Graph (DAG) for the booking process, including at least one unobserved variable.
- Run causal-discovery algorithms (PC and GES) and compare the discovered structure to your hand-drawn DAG.
- Apply identification criteria (backdoor, frontdoor, do-calculus) to decide whether and how the causal effect can be estimated, and identify any bad controls (post-treatment colliders, descendants of colliders) that must be excluded from any adjustment set.
- Compute four numeric estimates of the Average Treatment Effect (ATE) — naïve, backdoor with observed controls, frontdoor, and at least one deliberate-bad-control variant — and explain why they differ.
- Reflect on what your analysis can and cannot conclude.
The dataset is bilingual-ready: students may submit in R or Python. The case is motivated by Bach et al. (2024)’s sensitivity analysis of Booking.com cancellations and the DoubleML hotel-booking case study. The data are simulated from a Structural Causal Model that has been calibrated to look realistic; this is disclosed so that you focus on the methodology rather than on real-world data quirks.
Dataset & Codebook
The dataset contains 5,000 reservations with 11 columns.
Download booking_assignment.csv (≈ 240 KB) — save this file into the same folder as your source file (.qmd or .ipynb). Your code should then read it with a plain relative path: read_csv("booking_assignment.csv") (R) or pd.read_csv("booking_assignment.csv") (Python).
| Column | Type | Description |
|---|---|---|
lead_time |
integer (days) | Days between booking and check-in. |
prior_bookings |
integer (count) | Number of prior bookings the guest has made with the chain. |
market_segment |
factor | Booking channel: Direct, TA-TO (travel agent / tour operator), Corporate, Online-TA. |
country |
factor | Top-5 source country (DEU, GBR, FRA, ESP, ITA) plus Other. |
deposit_type |
factor | NoDeposit, Refundable, NonRefund. |
meal_upsell |
binary (0/1) | Treatment. 1 = guest accepted half-board / full-board upsell at booking; 0 = bed & breakfast (default). |
expected_total_spend |
continuous (EUR) | Forecast total revenue per stay (room + ancillaries). |
pre_checkin_engagement |
integer (count) | Number of guest-initiated touches before check-in (app opens, email replies, etc.). |
is_canceled |
binary (0/1) | Outcome. 1 = booking was cancelled before check-in; 0 = guest arrived. |
upgrade_offered |
binary (0/1) | 1 = hotel sent the guest a free room-upgrade offer pre-arrival; 0 = no offer. The hotel’s offer-decision system uses both the booking’s meal-upsell signal and an internal cancellation-risk score. |
upgrade_clicked |
binary (0/1) | 1 = guest clicked the upgrade-offer email; 0 = no click. |
import pandas as pd
dat = pd.read_csv("booking_assignment.csv")
print(dat.shape)
dat.head()Step 1 — Develop a Causal DAG (20%)
Before touching the data, draw the DAG you believe describes the booking process. Use ggdag (R) or networkx (Python) to render it inline in your report.
Required:
- At least one unobserved variable (latent node), justified from domain reasoning.
- An explicit story for every edge you draw: cite a course concept, a paper, or a plausible booking-system mechanism. Edges drawn without justification will lose points.
- A clear distinction between the treatment node, the outcome node, the mediators (if any), the observed confounders, the unobserved confounder(s), and any post-treatment colliders or their descendants.
Hints (not a checklist; think first, then read):
- The treatment is
meal_upsell; the outcome isis_canceled. What plausibly affects both? - Are
expected_total_spendandpre_checkin_engagementcauses of the upsell or consequences of it? What does the codebook tell you? - A latent variable is one that exists but is not in the CSV. Name it and motivate it.
- For each observed column, ask: is it recorded before
meal_upsell, betweenmeal_upsellandis_canceled, or afteris_canceled? Read the codebook description carefully — the timing matters for which arrows are admissible. - Watch for colliders (nodes that have two or more incoming arrows from variables on the \(D\)–\(Y\) paths). They are easy to spot in the DAG once drawn — and dangerous to condition on.
Step 2 — Causal Discovery and Comparison (20%)
Run two discovery algorithms on the observed columns of the dataset:
- PC algorithm (constraint-based): R
pcalg::pc(), Pythoncausallearn.search.ConstraintBased.PC. - GES algorithm (score-based): R
pcalg::ges(), Pythoncausallearn.search.ScoreBased.GES.
Categorical variables (market_segment, country, deposit_type) must be one-hot encoded before either algorithm sees them. Use fastDummies::dummy_cols() (R) or pd.get_dummies() (Python).
Required output:
- The CPDAG returned by each algorithm (visualised or printed as an edge list).
- A side-by-side comparison table with three columns: edge in your hand-drawn DAG, edge in PC’s output, edge in GES’s output. Mark agreements and disagreements.
- A diagnostic discussion (~1 page) on the disagreements:
- Where do the algorithms agree with your hand-drawn DAG? Why is that reassuring?
- Where do they disagree? What is the most plausible explanation for each disagreement (algorithm assumptions violated, latent confounder not modelled, sample size, finite-sample noise, etc.)?
- Crucially: does either algorithm recover your latent variable? If not, what does this tell you about discovery’s limits?
Step 3 — Identification (20%)
Apply the three identification criteria to your DAG:
3a — Backdoor
- Use
dagitty::adjustmentSets()(R) orpgmpy.inference.CausalInference.get_all_backdoor_adjustment_sets()(Python) to find all minimal sufficient adjustment sets in your DAG. - Discuss whether each set is achievable in practice — i.e., are all variables in the set actually in the dataset?
3a-bis — Bad controls (mandatory)
For each observed column in the dataset, classify it as one of:
- Pre-treatment confounder — safe to include in the adjustment set.
- Mediator on the \(D \to Y\) path — must not be in the backdoor adjustment set; relevant for frontdoor identification.
- Post-treatment collider or descendant of a collider — must not be in any adjustment set; conditioning on it introduces spurious dependence.
- Other (irrelevant) — neither helps nor hurts.
State your classification in a small table, and verify your answer programmatically using dagitty::isAdjustmentSet() (R) or pgmpy.inference.CausalInference.is_valid_backdoor_adjustment_set() (Python): show that adding the bad-control candidate to your backdoor set returns FALSE.
3b — The frontdoor-forcing sub-question
3c — Do-calculus
Encode your DAG as an ADMG (with bidirected edges representing the latent confounder), pass it to dosearch in R, and report the closed-form identifying expression for \(P(\text{is\_canceled} \mid do(\text{meal\_upsell}))\). Python users may rely on R for this step (no Python implementation has feature parity with dosearch for ADMGs); cite y0 as a developing alternative.
Step 4 — Four-Way Numeric ATE (15%)
Estimate the ATE four ways and put the numbers in a table:
| Estimator | What it computes | Required formula / code |
|---|---|---|
| Naïve diff-in-means | \(\bar Y_{D=1} - \bar Y_{D=0}\) | One-line mean(...) - mean(...) (R) or .groupby().mean() (Python). |
| Backdoor with observed X (good controls only) | OLS of is_canceled on meal_upsell + your observed pre-treatment adjustment set |
lm(is_canceled ~ meal_upsell + ...) (R) / statsmodels.formula.api.ols(...) (Python). |
| Bad-control variant (deliberately wrong) | Same OLS as the good-controls row, but add at least one of the bad-control candidates you identified in 3a-bis to the regression | lm(is_canceled ~ meal_upsell + good_controls + <bad_control>). Report the coefficient on meal_upsell. |
| Frontdoor through your mediator(s) | Plug-in estimator from Step 3b | Empirical \(P(M \mid D)\), outcome regression \(E[Y \mid M, D]\), then sum over \(m\) and \(d'\) as the formula prescribes. |
Required discussion:
- Which estimator do you trust most and why?
- If the four numbers differ markedly, what does that tell you about which assumptions are doing the work in each estimator?
- Compare the bad-control number to the good-control backdoor number. Is the bad-control estimate closer to or further from what you eventually conclude is the right ATE? Interpret in one or two sentences why adding the wrong control changed the answer in the direction it did.
- If the sign of the estimate flips between estimators, that is a major finding — interpret it carefully. Which causal direction would you report to the hotel chain’s product team?
Step 5 — Reflection (15%)
Write a one-page reflection covering:
- Strengths and limitations of your analysis. What assumptions are doing the most work? Where are they fragile?
- What additional data (variables, experiments, external sources) would close the identification gap?
- Discovery vs. identification. What is the appropriate role of an algorithmic CPDAG in a causal pipeline like this one?
- AI-use disclosure (per course policy). If you used an LLM to explain a concept, debug code, or draft prose, state where and what you verified independently.
Rubric
| Criterion | Weight | Excellent (1) | Good (2) | Needs Improvement (3) | Missing/Inadequate (4–5) |
|---|---|---|---|---|---|
| Dataset & question framing | 10% | Clear causal question, codebook respected, latent-variable suspicion stated upfront. | Clear question, minor gaps. | Question vague or codebook ignored. | No causal question or wrong column used as treatment. |
| DAG (Step 1) | 20% | 8–12 nodes, every edge justified, latent variable stated and motivated. | Most edges justified. | Several edges unjustified; latent missing or unmotivated. | DAG missing or shows misunderstanding of d-separation. |
| Discovery & comparison (Step 2) | 20% | Both algorithms run, encoded correctly, full comparison table, lucid diagnostic of disagreements. | One algorithm + comparison; diagnostic acceptable. | Only one algorithm or no comparison; weak diagnostic. | Neither algorithm runs / output ignored. |
| Identification (Step 3) | 20% | All three criteria correctly applied; mandatory frontdoor sub-question solved; bad-control candidates correctly classified and verified programmatically (isAdjustmentSet / is_valid_backdoor_adjustment_set returns FALSE for the proposed bad sets). |
Minor errors in one criterion, or missing frontdoor sub-question, or bad-control classification incomplete. | Multiple criteria misapplied; bad-control variants ignored or conflated with good controls. | No identification analysis. |
| Four-way ATE (Step 4) | 15% | Four numeric estimates presented (naïve, good-controls backdoor, deliberate-bad-control variant, frontdoor) with sign- and magnitude interpretation, including a one- or two-sentence comparison of the bad-control vs. good-control estimate. | One estimate missing or weakly interpreted. | Estimates produced but not interpreted, or bad-control variant omitted. | No numeric estimates. |
| Reflection (Step 5) | 15% | Honest, specific, actionable; AI use disclosed. | Mostly clear; minor gaps. | Generic or missing AI disclosure. | Missing reflection. |
In addition to step-specific scoring, the general evaluation criteria apply (correctness, code quality, communication clarity, reproducibility — see the syllabus for the full table).
Submission
Content
- A written report of 8–10 pages (selectively displayed code, figures, references) clearly structured per the steps above.
- Annotated, reproducible code (R or Python). The instructor must be able to open your source file, install the listed packages, and re-render your report end-to-end on a clean machine.
Format
- Submit one source file plus the rendered PDF. The source file may be either a Quarto Markdown (
.qmd) or a Jupyter notebook (.ipynb) — both are accepted, in either R or Python. - File names:
[YourLastName]_Assignment_1.{qmd|ipynb}and[YourLastName]_Assignment_1.pdf. - Bundle the two files into a single zip:
[YourLastName]_Assignment_1.zip. - Do not include
booking_assignment.csvin the zip. When grading, the instructor will place the CSV in the same folder as your source file before re-rendering. Your code should therefore read the file with a plain relative path (read_csv("booking_assignment.csv")), not a path that traverses parent directories. - If your code reads from any additional external file you created (e.g., a hand-built lookup table, a saved discovery output), include those alongside the source file in the zip — but never the provided
booking_assignment.csv.
Deadline
- Sunday, 17 May 2026, 23:59 (Hamburg local time).
- Upload to the StudIP folder (link in the course shell).
Quiz
- A 15-minute closed-book quiz on this material at the start of the Monday 18 May 2026 PBL session. Quiz covers DAGs, identification criteria, and the interpretation of your three-way ATE comparison.