diff --git a/.gitignore b/.gitignore index 7d095cc59d..16ce9f1614 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,6 @@ docs/source/example_notebooks/lalonde.csv dowhy-docs *.DS_Store + +# FoundCause model cache created by gcm_causal_discovery_foundcause.ipynb +docs/source/example_notebooks/foundcause_model/ diff --git a/docs/source/_static/gcm-foundcause-discovered-graph.png b/docs/source/_static/gcm-foundcause-discovered-graph.png new file mode 100644 index 0000000000..3150bb81fa Binary files /dev/null and b/docs/source/_static/gcm-foundcause-discovered-graph.png differ diff --git a/docs/source/example_notebooks/gcm_causal_discovery_foundcause.ipynb b/docs/source/example_notebooks/gcm_causal_discovery_foundcause.ipynb new file mode 100644 index 0000000000..57dea031fb --- /dev/null +++ b/docs/source/example_notebooks/gcm_causal_discovery_foundcause.ipynb @@ -0,0 +1,632 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Root Cause Analysis with an Unknown Causal Graph and a Hidden Confounder Using a Foundational Causal Discovery Model\n", + "\n", + "Every graphical causal model (GCM) analysis starts the same way: *\"First, define your causal graph.\"* But what if the causal graph is unknown?\n", + "\n", + "This notebook demonstrates that a plain CSV of observed data can be sufficient: we infer the causal graph directly from\n", + "the data, learn the data generating process on top of it (the GCM) and then run the causal analyses. No prior knowledge\n", + "about the system's structure is required.\n", + "\n", + "We look at a synthetic scenario of a **bottling line** in a beverage plant. The line streams nine sensor readings: mixer\n", + "speed and temperature, product viscosity, line speed, fill pressure, fill level, seal integrity, defect rate and\n", + "throughput. The sensors were added ad hoc over years and we assume there is no maintained wiring diagram of how\n", + "the stages influence each other.\n", + "\n", + "Instead of defining the graph by hand, we can use causal discovery methods to infer it from observational data alone.\n", + "This is a notoriously hard problem. Observational data by itself does not uniquely determine a causal graph, so\n", + "'classical' discovery algorithms (see [causal-learn](https://github.com/py-why/causal-learn) for a broad collection) have to rely on\n", + "specific and often strong assumptions about the data generating process, such as linear relationships, additive noise\n", + "or particular distribution families. In practice, we rarely know whether these assumptions hold for our data, which\n", + "makes it unclear which algorithm to trust. Causal discovery foundation models take a different route: they are trained\n", + "on vast amounts of synthetic data covering a wide variety of generating processes and learn to map a dataset directly\n", + "to its causal graph. Rather than committing to one fixed set of assumptions, such a model has seen many kinds of causal\n", + "mechanisms during training. Here, we use [FoundCause](https://github.com/amazon-science/foundcause), a pretrained\n", + "foundation model for causal discovery that predicts the causal graph from raw observational data in a single forward\n", + "pass (see the related paper [Blöbaum, Balasubramanian & Kasiviswanathan, 2026](https://arxiv.org/abs/2606.17516) for\n", + "details). To make the scenario more challenging and realistic, our example even contains a *hidden confounder*: a\n", + "variable that influences several sensors but is itself not part of the recorded data. As we will see, the GCM-based\n", + "analyses remain robust in its presence. We use DoWhy's GCM module on the *discovered* graph to answer real production\n", + "questions:\n", + "\n", + "1. **Discover** the causal graph of the line from sensor data alone and evaluate it against the ground truth (which we\n", + " know in this notebook).\n", + "2. **Falsify**: test the discovered graph against the data with `falsify_graph` before trusting it.\n", + "3. **Root cause analysis**: attribute a sudden defect-rate spike to the responsible upstream sensor.\n", + "4. **Distribution change**: explain why throughput *dropped* after operations increased the line speed.\n", + "\n", + "Because the data is simulated from a ground-truth structural causal model (built with DoWhy's own random SCM\n", + "generator), we can honestly verify every answer." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "⚠️ **Model download required.** This notebook uses the FoundCause inference code and its pretrained weights\n", + "(**~1.6 GB**), which are downloaded from the official GitHub release the first time the notebook is executed. Both\n", + "files are cached in a `foundcause_model` folder next to this notebook, so subsequent runs load them from disk.\n", + "FoundCause runs on [PyTorch](https://pytorch.org/) (install with `pip install torch` if it is not already available).\n", + "Inference runs on CPU (no GPU required); the discovery step takes about 30 seconds per run on a laptop." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import hashlib\n", + "import os\n", + "import sys\n", + "import urllib.request\n", + "\n", + "FOUNDCAUSE_DIR = \"foundcause_model\"\n", + "FILES = {\n", + " \"foundcause.py\": (\n", + " \"https://raw.githubusercontent.com/amazon-science/foundcause/v1.0/foundcause.py\",\n", + " \"43f2be8680cc90790a0322b6774b6969505f454f271c597ce418f935448ed663\",\n", + " ),\n", + " \"checkpoint.pt\": (\n", + " \"https://github.com/amazon-science/foundcause/releases/download/v1.0/checkpoint.pt\",\n", + " \"a24b6b4b5fd0d380361d4a0699b2ec993a6aa2da91f90bb384a383e01f55cb17\",\n", + " ),\n", + "}\n", + "\n", + "os.makedirs(FOUNDCAUSE_DIR, exist_ok=True)\n", + "for filename, (url, sha256) in FILES.items():\n", + " path = os.path.join(FOUNDCAUSE_DIR, filename)\n", + " if not os.path.exists(path):\n", + " print(f\"Downloading {filename} ...\")\n", + " urllib.request.urlretrieve(url, path)\n", + " digest = hashlib.sha256()\n", + " with open(path, \"rb\") as f:\n", + " for chunk in iter(lambda: f.read(1024 * 1024), b\"\"):\n", + " digest.update(chunk)\n", + " digest = digest.hexdigest()\n", + " if digest != sha256:\n", + " os.remove(path)\n", + " raise RuntimeError(f\"SHA-256 mismatch for {filename}; deleted the file, please re-run this cell.\")\n", + " print(f\"{filename}: OK\")\n", + "\n", + "sys.path.insert(0, FOUNDCAUSE_DIR)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 1: Simulate the bottling line\n", + "\n", + "We first need a plant to analyze. We define the *true* causal structure of the line (the causal graph nobody at the\n", + "plant knows) and let DoWhy's random SCM generator (`dowhy.gcm.data_generator`) assign a random causal mechanism to\n", + "every node. This gives us a ground truth to grade the discovery against later.\n", + "\n", + "The line works as follows: a **mixer** (speed, temperature) determines the product **viscosity**. Viscosity and the\n", + "**line speed** drive the **fill pressure**, which in turn determines the **fill level** of each bottle. The sealing\n", + "station's **seal integrity** and the fill level determine the **defect rate**. Defects together with line speed\n", + "determine the final **throughput**.\n", + "\n", + "There is one more variable: **ambient humidity** affects both viscosity and seal integrity. Crucially, the plant has\n", + "*no humidity sensor*, so this variable exists physically but is not logged. This makes it a **hidden confounder**: it\n", + "influences two of the recorded sensors while being unobserved itself. Keep it in mind when we look at the discovered\n", + "graph." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import networkx as nx\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "from dowhy import gcm\n", + "from dowhy.gcm.util.general import set_random_seed\n", + "from dowhy.utils import bar_plot, plot\n", + "\n", + "set_random_seed(0)\n", + "\n", + "causal_graph_true = nx.DiGraph(\n", + " [\n", + " (\"mixer_speed\", \"viscosity\"),\n", + " (\"mixer_temperature\", \"viscosity\"),\n", + " (\"ambient_humidity\", \"viscosity\"),\n", + " (\"ambient_humidity\", \"seal_integrity\"),\n", + " (\"viscosity\", \"fill_pressure\"),\n", + " (\"line_speed\", \"fill_pressure\"),\n", + " (\"fill_pressure\", \"fill_level\"),\n", + " (\"line_speed\", \"fill_level\"),\n", + " (\"fill_level\", \"defect_rate\"),\n", + " (\"seal_integrity\", \"defect_rate\"),\n", + " (\"defect_rate\", \"throughput\"),\n", + " (\"line_speed\", \"throughput\"),\n", + " ]\n", + ")\n", + "plot(causal_graph_true)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now we assign random causal mechanisms to this graph and draw observational samples. Two generator settings are\n", + "adjusted for this scenario: `prob_discretised=0` (the sensor readings are continuous) and `prob_non_additive_noise=0`\n", + "(keeps the ground truth an additive noise model to simplify the overall setting here). Everything else, such as which nodes get linear vs. neural network mechanisms and the\n", + "noise distributions, is up to the generator.\n", + "\n", + "One variable is special: the line speed is not a free-floating measurement but an operator-selected *set-point*, so we\n", + "replace its root distribution with a discrete one taking five levels. The generator produces standardized values, so\n", + "we map each column to realistic engineering units (RPM, °C, cP, bar, mL, N, defects per 10k bottles, units/hour) to\n", + "make the dataset look like an actual production export." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from scipy import stats\n", + "\n", + "from dowhy.gcm import ScipyDistribution\n", + "from dowhy.gcm.data_generator import DataGeneratorConfig, assign_random_fcms\n", + "\n", + "scm_ground_truth = assign_random_fcms(\n", + " causal_graph_true,\n", + " DataGeneratorConfig(\n", + " prob_discretised=0.0,\n", + " prob_non_additive_noise=0.0,\n", + " prob_linear_mechanism=0.5,\n", + " prob_clipped_positive=0.0,\n", + " prob_clipped_negative=0.0,\n", + " ),\n", + ")\n", + "\n", + "# line_speed is an operator-selected set-point: one of five discrete levels\n", + "scm_ground_truth.set_causal_mechanism(\"line_speed\", ScipyDistribution(stats.randint, low=-2, high=3))\n", + "\n", + "# offset and scale per column: standardized simulator output -> engineering units\n", + "ENGINEERING_UNITS = {\n", + " \"mixer_speed\": (120.0, 12.0), # RPM\n", + " \"mixer_temperature\": (78.0, 3.5), # deg C\n", + " \"ambient_humidity\": (55.0, 8.0), # % relative humidity\n", + " \"line_speed\": (220.0, 20.0), # bottles/min, set-points 180/200/220/240/260\n", + " \"viscosity\": (350.0, 45.0), # centipoise\n", + " \"fill_pressure\": (2.8, 0.35), # bar\n", + " \"fill_level\": (500.0, 6.0), # mL\n", + " \"seal_integrity\": (55.0, 7.0), # N (seal peel force)\n", + " \"defect_rate\": (50.0, 12.0), # defects per 10k bottles\n", + " \"throughput\": (12600.0, 900.0), # units/hour\n", + "}\n", + "\n", + "\n", + "def to_engineering_units(df):\n", + " df = df.copy()\n", + " for column, (offset, scale) in ENGINEERING_UNITS.items():\n", + " if column in df.columns:\n", + " df[column] = offset + scale * df[column]\n", + " return df\n", + "\n", + "\n", + "full_plant_data = to_engineering_units(gcm.draw_samples(scm_ground_truth, 2000))\n", + "\n", + "# The plant has no humidity sensor: the logged data does not contain this column!\n", + "sensor_data = full_plant_data.drop(columns=[\"ambient_humidity\"])\n", + "sensor_data.head()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "sensor_data.describe().loc[[\"mean\", \"std\", \"min\", \"max\"]].round(1).T" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Finally, we generate the data for two events that we will investigate later in the notebook, both created from the\n", + "ground-truth SCM via interventions:\n", + "\n", + "- **A quality incident**: the sealing unit degrades and its seal force drops by four standard deviations. We will see\n", + " this as a defect-rate spike and root-cause it in Step 5.\n", + "- **A process change**: operations raises the line-speed set-point by one level. We will analyze its surprising effect\n", + " on throughput in Step 6." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from dowhy.gcm.whatif import interventional_samples\n", + "\n", + "# quality incident: the sealing unit degrades (seal force drops by 4 standard deviations)\n", + "seal_drop = 4.0 * sensor_data[\"seal_integrity\"].std() / ENGINEERING_UNITS[\"seal_integrity\"][1]\n", + "incident_data = to_engineering_units(\n", + " interventional_samples(\n", + " scm_ground_truth,\n", + " {\"seal_integrity\": lambda x: x - seal_drop},\n", + " num_samples_to_draw=3,\n", + " )\n", + ").drop(columns=[\"ambient_humidity\"])\n", + "\n", + "# process change: line-speed set-point raised by one level (+20 bottles/min),\n", + "# clamped so it stays within the five available set-points (up to 260 bottles/min)\n", + "data_after_speedup = to_engineering_units(\n", + " interventional_samples(\n", + " scm_ground_truth,\n", + " {\"line_speed\": lambda x: np.minimum(x + 1, 2)},\n", + " num_samples_to_draw=3000,\n", + " )\n", + ").drop(columns=[\"ambient_humidity\"])" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The ground truth has now served its purpose. From here on, everything works exclusively with the exported data: the\n", + "historical `sensor_data`, the three `incident_data` snapshots and the `data_after_speedup` export. We pretend to have\n", + "no knowledge of the graph or the mechanisms that generated them." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 2: Discover the causal graph with FoundCause\n", + "\n", + "FoundCause is a ~139M-parameter transformer trained entirely on synthetic data to map a dataset (samples × variables)\n", + "directly to a causal graph: no per-dataset training, no independence tests to configure, just a forward pass. It\n", + "handles 2–50 variables well.\n", + "\n", + "We load the pretrained checkpoint and wrap the model's `predict` function into a small helper that returns the\n", + "discovered graph as a `networkx.DiGraph`. Since FoundCause does not guarantee acyclicity, we apply the greedy DAG\n", + "post-processing from the official repository (`predict.py`), which inserts edges in order of decreasing probability\n", + "while skipping any edge that would create a cycle. A DAG is exactly what `dowhy.gcm` needs downstream." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "from foundcause import CausalDiscoveryTransformer, ModelConfig, predict\n", + "\n", + "device = torch.device(\"cpu\")\n", + "foundcause_model = CausalDiscoveryTransformer(ModelConfig())\n", + "checkpoint = torch.load(\n", + " os.path.join(FOUNDCAUSE_DIR, \"checkpoint.pt\"), map_location=device, weights_only=False\n", + ")\n", + "state = {k.replace(\"_orig_mod.\", \"\"): v for k, v in checkpoint[\"model_state_dict\"].items()}\n", + "foundcause_model.load_state_dict(state, strict=False)\n", + "foundcause_model.eval()\n", + "print(\"FoundCause model loaded.\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def enforce_dag(edge_probabilities, threshold):\n", + " \"\"\"Greedy DAG post-processing from the official FoundCause predict.py: add edges in\n", + " descending probability order, skipping any that would create a cycle.\"\"\"\n", + " num_nodes = edge_probabilities.shape[0]\n", + " candidates = [\n", + " (edge_probabilities[i, j], i, j)\n", + " for i in range(num_nodes)\n", + " for j in range(num_nodes)\n", + " if i != j and edge_probabilities[i, j] > threshold\n", + " ]\n", + " candidates.sort(reverse=True)\n", + " graph = nx.DiGraph()\n", + " graph.add_nodes_from(range(num_nodes))\n", + " adjacency = np.zeros_like(edge_probabilities)\n", + " for _, i, j in candidates:\n", + " if nx.has_path(graph, j, i):\n", + " continue\n", + " graph.add_edge(i, j)\n", + " adjacency[i, j] = 1.0\n", + " return adjacency\n", + "\n", + "\n", + "def discover_graph(data):\n", + " \"\"\"Run FoundCause on a DataFrame and return the discovered DAG.\"\"\"\n", + " X = data.to_numpy().astype(np.float32)\n", + " variable_names = list(data.columns)\n", + "\n", + " result = predict(foundcause_model, X, device)\n", + " adjacency = enforce_dag(result[\"probabilities\"], result[\"threshold\"])\n", + "\n", + " discovered = nx.DiGraph()\n", + " discovered.add_nodes_from(variable_names)\n", + " for i, source in enumerate(variable_names):\n", + " for j, target in enumerate(variable_names):\n", + " if adjacency[i, j]:\n", + " discovered.add_edge(source, target)\n", + " return discovered\n", + "\n", + "\n", + "causal_graph_discovered = discover_graph(sensor_data)\n", + "plot(causal_graph_discovered)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "That is the entire discovery step: within a few seconds, we recovered a causal graph from the observed data with a\n", + "single function call on the raw sensor DataFrame. The result is\n", + "not perfect though. Since we know the true graph in this notebook, we can grade it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "true_edges = set(\n", + " (source, target) for source, target in causal_graph_true.edges if \"ambient_humidity\" not in (source, target)\n", + ")\n", + "discovered_edges = set(causal_graph_discovered.edges)\n", + "\n", + "correct = discovered_edges & true_edges\n", + "extra = discovered_edges - true_edges\n", + "missed = true_edges - discovered_edges\n", + "\n", + "print(f\"True edges among logged sensors: {len(true_edges)}\")\n", + "print(f\"Recovered: {len(correct)}/{len(true_edges)} (recall {len(correct) / len(true_edges):.0%}, \"\n", + " f\"precision {len(correct) / len(discovered_edges):.0%})\")\n", + "print(f\"\\nMissed edges: {sorted(missed) if missed else 'none'}\")\n", + "print(\"Extra edges:\")\n", + "for source, target in sorted(extra):\n", + " print(f\" {source} -> {target}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "FoundCause recovered **every true edge** of the production line, with none missed and none pointing in the wrong\n", + "direction. It added a few edges that are not part of the true graph, mostly \"shortcuts\" along strong causal pathways\n", + "(e.g. `fill_pressure -> throughput` parallels the true path through `fill_level` and `defect_rate`). As long as such\n", + "additional edges are not wrongly directed but reflect the causal order, this is usually fine, which is the case here.\n", + "Since we fit a causal mechanism for each node given its parents, a superfluous parent simply ends up contributing\n", + "(close to) nothing to the fitted mechanism; the mechanism learns which parents actually matter. Missing or reversed\n", + "edges are more harmful, because they remove or distort causal pathways that no amount of fitting can recover.\n", + "\n", + "The edge between `seal_integrity` and `viscosity` deserves a remark. Physically, the sealing station cannot influence\n", + "the viscosity of the liquid; this dependence is induced by the **hidden confounder**, since ambient humidity affects\n", + "both variables but is not logged. We keep the graph as it is. As the following analyses show, the GCM-based methods\n", + "remain robust against this artifact of the hidden confounder." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 3: Falsify the discovered graph before trusting it\n", + "\n", + "A discovered graph should not be taken on faith, especially not for production decisions. DoWhy provides a\n", + "permutation-based falsification test ([Eulig et al., 2023](https://arxiv.org/abs/2305.09565)) that checks whether the\n", + "given DAG explains the conditional-independence structure of the data significantly better than random graphs over the\n", + "same nodes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from dowhy.gcm.falsify import falsify_graph\n", + "\n", + "falsification_result = falsify_graph(causal_graph_discovered, sensor_data, n_permutations=20, plot_histogram=True)\n", + "falsification_result" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The test does **not reject** the discovered DAG: it is informative (no random permutation of it entails the same\n", + "independencies) and violates far fewer local Markov conditions than random graphs. We now have a machine-discovered,\n", + "data-validated causal graph obtained without manually defining a single edge and can proceed to actual causal queries." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 4: Fit a GCM on the discovered graph\n", + "\n", + "We fit an invertible structural causal model on the discovered graph, letting DoWhy pick a suitable causal mechanism\n", + "for every node automatically. This learns the data generating process from the sensor CSV alone." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "scm_discovered = gcm.InvertibleStructuralCausalModel(causal_graph_discovered)\n", + "summary = gcm.auto.assign_causal_mechanisms(scm_discovered, sensor_data)\n", + "gcm.fit(scm_discovered, sensor_data)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 5: Root cause analysis of a defect-rate spike\n", + "\n", + "Recall the quality incident we generated in Step 1: the sealing unit degraded and its seal force dropped by four\n", + "standard deviations. On the quality dashboard, this materializes as three production snapshots with a defect rate far\n", + "above normal. The plant team sees only these sensor readings:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "comparison = pd.DataFrame(\n", + " {\"incident (mean of 3)\": incident_data.mean().round(1), \"normal (mean)\": sensor_data.mean().round(1)}\n", + ")\n", + "comparison" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Which part of the line is responsible for the elevated defect rate? We answer this with `attribute_anomalies`.\n", + "Note that everything used in this analysis is learned from data: the causal graph came from FoundCause and the causal\n", + "mechanisms were fitted on the sensor CSV. The ground-truth SCM played no role beyond generating the data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "attributions = gcm.attribute_anomalies(scm_discovered, \"defect_rate\", anomaly_samples=incident_data)\n", + "\n", + "bar_plot({node: scores.mean() for node, scores in attributions.items()}, ylabel=\"Anomaly attribution score\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The attribution correctly identifies **`seal_integrity`** as the dominant root cause of the defect spike: the\n", + "sealing unit, exactly where we injected the degradation." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Step 6: Distribution change: why did the line speed-up backfire?\n", + "\n", + "Now to the second event from Step 1: operations raised the line-speed set-point by one level to catch up with demand.\n", + "Counterintuitively, throughput *dropped*. Management wants to know what changed in the system." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(f\"line_speed: {sensor_data['line_speed'].mean():.0f} -> {data_after_speedup['line_speed'].mean():.0f} bottles/min\")\n", + "print(f\"throughput: {sensor_data['throughput'].mean():.0f} -> {data_after_speedup['throughput'].mean():.0f} units/hour\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`distribution_change` compares the historical export against the new one and attributes the change in the\n", + "throughput distribution to the nodes whose causal mechanisms actually changed between the two datasets. Again, this\n", + "analysis runs entirely on the discovered graph and the observed data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "change_attributions = gcm.distribution_change(\n", + " gcm.ProbabilisticCausalModel(causal_graph_discovered), sensor_data, data_after_speedup, \"throughput\"\n", + ")\n", + "\n", + "bar_plot(change_attributions, ylabel=\"Change attribution score\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The analysis attributes the throughput change to the **`line_speed`** mechanism (the set-point change) and not to\n", + "any downstream mechanism. In other words: nothing on the line is broken; the speed-up itself backfired. The discovered\n", + "graph explains why: `line_speed` raises `defect_rate` (via the fill dynamics) and the additional defects\n", + "more than offset the faster line." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Conclusion\n", + "\n", + "All we started with was a CSV export of sensor readings. From that single artifact, we inferred the causal graph with\n", + "a foundation model, learned the data generating process on top of it and answered real production questions: graph\n", + "falsification, root cause analysis and distribution-change attribution, **without manually defining a single causal\n", + "edge**. Because we simulated the plant with DoWhy's random SCM generator, every answer could be verified against\n", + "ground truth, including robustness of the analyses to a hidden confounder.\n", + "\n", + "A few notes:\n", + "\n", + "- **Discovery is not perfect.** FoundCause recovered all true edges here but added extra ones. The fitted causal\n", + " mechanisms are fairly robust to superfluous parents, but you should still validate a discovered graph with\n", + " `falsify_graph`, with domain experts, or both, before making decisions with it.\n", + "- **Hidden confounders matter.** An unlogged common cause showed up as a physically impossible edge in the discovered\n", + " graph. The downstream analyses were robust to it here, but treat such artifacts as a signal to look for missing\n", + " variables, not as noise.\n", + "- **Foundation models are not the only option.** If more details about the data generation process are known, certain\n", + " causal discovery algorithms that explicitly exploit these asymmetries could be a better fit. For example, if the\n", + " relationships are known to be linear with non-Gaussian noise, or to follow an additive noise model, algorithms\n", + " tailored to exactly these assumptions (see [causal-learn](https://github.com/py-why/causal-learn) for a broad collection of such algorithms) can\n", + " use that knowledge in ways a general-purpose model cannot. A foundation model like\n", + " [FoundCause](https://github.com/amazon-science/foundcause)\n", + " ([Blöbaum, Balasubramanian & Kasiviswanathan, 2026](https://arxiv.org/abs/2606.17516)) shines when such knowledge is\n", + " not available.\n", + "- **Seeds.** This notebook fixes the random seed once at the beginning for reproducibility. Different seeds might lead\n", + " to slightly different results due to the strong randomness in the data generation process." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.6" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/docs/source/example_notebooks/nb_index.rst b/docs/source/example_notebooks/nb_index.rst index 6ff810f3d8..b4cd3f8a5d 100644 --- a/docs/source/example_notebooks/nb_index.rst +++ b/docs/source/example_notebooks/nb_index.rst @@ -209,6 +209,15 @@ Real world-inspired examples .. grid:: 2 + .. grid-item-card:: :doc:`gcm_causal_discovery_foundcause` + + .. image:: ../_static/gcm-foundcause-discovered-graph.png + :height: 120px + :align: center + +++ + | **Level:** Advanced + | **Task:** Causal discovery with a foundation model and root cause analysis via GCM + .. grid-item-card:: :doc:`gcm_mta_incrementality_time_decay` .. image:: ../_static/gcm_mta_incrementality_time_decay.png @@ -450,6 +459,7 @@ Miscellaneous gcm_falsify_dag counterfactual_fairness_dowhy sales_attribution_intervention + gcm_causal_discovery_foundcause gcm_chest_xray_causal_inference .. toctree::