{ "cells": [ { "cell_type": "markdown", "id": "intro", "metadata": {}, "source": [ "# Why Did This Delivery Forecast Jump? Separating Changed Conditions from a Model Update\n", "\n", "At 09:00, ETA model v1 predicts **28.5 minutes remaining** for delivery **DEL-042**. Fifteen minutes later, model v2 predicts **54.8 minutes remaining** for the same delivery, even though the courier is now 2 km closer! Congestion worsened, three stops were added, rain began, and by 09:15 the platform's new model was active.\n", "\n", "This raises an attribution question: **How much of the 26.3-minute increase came from each changed condition, and how much came from the model update?**\n", "\n", "We will use `dowhy.gcm.unit_change` to build an order-independent attribution that exactly reconstructs the forecast change, first for DEL-042 and then across a batch of deliveries.\n", "\n", "" ] }, { "cell_type": "markdown", "id": "attribution-target", "metadata": {}, "source": [ "## What exactly are we explaining?\n", "\n", "DEL-042 is one statistical unit observed in two contexts. DoWhy calls the earlier context the **background** and the later context the **foreground**:\n", "\n", "| | Background | Foreground |\n", "|:---|:---:|:---:|\n", "| Snapshot | 09:00 | 09:15 |\n", "| Delivery state | x09:00 | x09:15 |\n", "| Prediction mechanism | fv1 | fv2 |\n", "| Forecast | fv1(x09:00) = 28.5 | fv2(x09:15) = 54.8 |\n", "\n", "Two things changed between these endpoints: the input vector $x$ **and** the prediction function $f$. Their endpoint contrast is $f_{v2}(x_{09:15}) - f_{v1}(x_{09:00}) = 26.3$ minutes. Subtraction gives the total, but it cannot tell us how much to assign to remaining distance, congestion, stops, rain, or the model update. We want five contributions that sum back to 26.3 minutes exactly.\n", "\n", "Typical feature-attribution explanations hold the prediction mechanism fixed. Here, replacing the mechanism is itself one of the possible explanations. This is the setting studied in [*Explaining the root causes of unit-level changes*](https://arxiv.org/abs/2206.12986) by Budhathoki, Michailidis, and Janzing, which uses counterfactual Shapley values to account for changes in both inputs and the function mapping them to an output.\n", "\n", "The target here is the change in the platform's **forecast**, not the effect of intervening on traffic or weather on actual delivery time." ] }, { "cell_type": "code", "execution_count": null, "id": "setup", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import pandas as pd\n", "\n", "import dowhy\n", "from dowhy import gcm\n", "\n", "SEED = 2026\n", "dowhy.enable_notebook_rendering()\n", "gcm.config.disable_progress_bars()" ] }, { "cell_type": "markdown", "id": "contexts", "metadata": {}, "source": [ "## 1. Pair the background and foreground delivery states\n", "\n", "Both contexts must describe the same unit with the same ordered feature schema. In production, these values would normally come from two logged prediction snapshots. We hand-construct one pair here so every contribution is transparent and exactly checkable; `unit_change` does not require synthetic data.\n", "\n", "Between 09:00 and 09:15, the courier travels 2 km, but congestion increases by 12 minutes, three stops are added, and rain intensity rises from 0 to 0.8." ] }, { "cell_type": "code", "execution_count": null, "id": "snapshots", "metadata": {}, "outputs": [], "source": [ "FEATURES = [\"remaining_km\", \"congestion_minutes\", \"stops_ahead\", \"rain_intensity\"]\n", "FEATURE_LABELS = {\n", " \"remaining_km\": \"Remaining distance\",\n", " \"congestion_minutes\": \"Congestion\",\n", " \"stops_ahead\": \"Stops ahead\",\n", " \"rain_intensity\": \"Rain intensity\",\n", " \"f\": \"ETA mechanism\",\n", "}\n", "\n", "delivery_id = pd.Index([\"DEL-042\"], name=\"delivery_id\")\n", "snapshot_0900 = pd.DataFrame([[6.5, 4.0, 1.0, 0.0]], columns=FEATURES, index=delivery_id)\n", "snapshot_0915 = pd.DataFrame([[4.5, 16.0, 4.0, 0.8]], columns=FEATURES, index=delivery_id)" ] }, { "cell_type": "markdown", "id": "mechanism-specification", "metadata": {}, "source": [ "### Specify the two prediction mechanisms\n", "\n", "To focus on attribution rather than model estimation, we specify two fixed, known linear mechanisms. Version 2 lowers the baseline and distance weight while responding more strongly to congestion, stops, and rain:\n", "\n", "$$\n", "f_{v1}(x) = 8 + 2.2d + 0.8c + 3.0s + 6.0r\n", "$$\n", "\n", "$$\n", "f_{v2}(x) = 5 + 2.0d + 1.1c + 3.8s + 10.0r.\n", "$$\n", "\n", "Here $d$ is remaining distance, $c$ is expected congestion delay, $s$ is the number of stops ahead, and $r$ is rain intensity. The same `unit_change` API also accepts nonlinear DoWhy `PredictionModel` implementations." ] }, { "cell_type": "code", "execution_count": null, "id": "models", "metadata": {}, "outputs": [], "source": [ "COEFFICIENTS_V1 = np.array([2.2, 0.8, 3.0, 6.0])\n", "COEFFICIENTS_V2 = np.array([2.0, 1.1, 3.8, 10.0])\n", "INTERCEPT_V1 = 8.0\n", "INTERCEPT_V2 = 5.0\n", "\n", "eta_v1 = gcm.ml.create_linear_regressor_with_given_parameters(COEFFICIENTS_V1, INTERCEPT_V1)\n", "eta_v2 = gcm.ml.create_linear_regressor_with_given_parameters(COEFFICIENTS_V2, INTERCEPT_V2)" ] }, { "cell_type": "code", "execution_count": null, "id": "eta-change", "metadata": {}, "outputs": [], "source": [ "eta_0900 = eta_v1.predict(snapshot_0900[FEATURES].to_numpy()).ravel()\n", "eta_0915 = eta_v2.predict(snapshot_0915[FEATURES].to_numpy()).ravel()\n", "eta_delta = eta_0915 - eta_0900\n", "\n", "paired_snapshots = pd.concat(\n", " [snapshot_0900, snapshot_0915],\n", " keys=[\"09:00\", \"09:15\"],\n", " names=[\"Snapshot\"],\n", ")\n", "paired_snapshots[\"ETA model\"] = [\"v1\", \"v2\"]\n", "paired_snapshots[\"Forecast (minutes remaining)\"] = [eta_0900[0], eta_0915[0]]\n", "paired_snapshots.rename(columns={name: FEATURE_LABELS[name] for name in FEATURES}).round(1)" ] }, { "cell_type": "markdown", "id": "order-problem", "metadata": {}, "source": [ "## 2. Why ordinary subtraction is not enough\n", "\n", "Subtracting the endpoint forecasts gives 26.3 minutes, but it does not allocate that difference. We could first substitute the 09:15 delivery conditions into v1 and then switch to v2. Or we could switch models at the 09:00 conditions and only then substitute the new conditions:\n", "\n", "
\n",
" change conditions\n",
" f_v1(x_09:00) --------------------> f_v1(x_09:15)\n",
" | |\n",
"switch to v2 | | switch to v2\n",
" v v\n",
" f_v2(x_09:00) --------------------> f_v2(x_09:15)\n",
" change conditions\n",
"\n",
"