{ "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.\n", "\n", "**A note of caution.** Causal discovery produces a *hypothesis* about the causal structure, not a verified ground\n", "truth. FoundCause assumes i.i.d. observational data and, like every causal discovery method, can still get edges wrong,\n", "most often their *orientation* (the paper reports that adjacency detection stays reliable while orientation accuracy\n", "drops as the number of variables grows). It was trained on graphs with 2 to 50 variables, and it is unclear how well it\n", "generalizes beyond that range. Always treat the discovered graph as a starting point to be validated, which we do below\n", "in Step 2 with `falsify_graph`, and sanity-check it against domain knowledge before trusting any downstream result." ] }, { "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. Keep\n", "the assumptions and limits from the note in the introduction in mind.\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": [ "⚠️ **The discovered graph is a hypothesis, not ground truth.** FoundCause does not guarantee correctness (or even\n", "acyclicity, which is why we post-process into a DAG above). It tends to recover the skeleton (which variables are\n", "connected) more reliably than the exact edge directions. Before relying on it, validate the graph, ideally by combining\n", "the falsification test in the next step with domain expertise." ] }, { "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 }