diff --git a/Untitled.ipynb b/Untitled.ipynb new file mode 100644 index 000000000..fbde50e50 --- /dev/null +++ b/Untitled.ipynb @@ -0,0 +1,256 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 9, + "id": "938fea3f-4cd8-476b-9e29-64c3c4d92d65", + "metadata": {}, + "outputs": [], + "source": [ + "import pandas as pd\n", + "import seaborn as sns" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "e0cb2aef-909f-4efd-90a8-98cd10ae6b64", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
PassengerIdSurvivedPclassNameSexAgeSibSpParchTicketFareCabinEmbarked
0103Braund, Mr. Owen Harrismale22.010A/5 211717.2500NaNS
1211Cumings, Mrs. John Bradley (Florence Briggs Th...female38.010PC 1759971.2833C85C
2313Heikkinen, Miss. Lainafemale26.000STON/O2. 31012827.9250NaNS
3411Futrelle, Mrs. Jacques Heath (Lily May Peel)female35.01011380353.1000C123S
4503Allen, Mr. William Henrymale35.0003734508.0500NaNS
\n", + "
" + ], + "text/plain": [ + " PassengerId Survived Pclass \\\n", + "0 1 0 3 \n", + "1 2 1 1 \n", + "2 3 1 3 \n", + "3 4 1 1 \n", + "4 5 0 3 \n", + "\n", + " Name Sex Age SibSp \\\n", + "0 Braund, Mr. Owen Harris male 22.0 1 \n", + "1 Cumings, Mrs. John Bradley (Florence Briggs Th... female 38.0 1 \n", + "2 Heikkinen, Miss. Laina female 26.0 0 \n", + "3 Futrelle, Mrs. Jacques Heath (Lily May Peel) female 35.0 1 \n", + "4 Allen, Mr. William Henry male 35.0 0 \n", + "\n", + " Parch Ticket Fare Cabin Embarked \n", + "0 0 A/5 21171 7.2500 NaN S \n", + "1 0 PC 17599 71.2833 C85 C \n", + "2 0 STON/O2. 3101282 7.9250 NaN S \n", + "3 0 113803 53.1000 C123 S \n", + "4 0 373450 8.0500 NaN S " + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "titanic = pd.read_csv(\"~/harvard_survery_data.csv\")\n", + "\n", + "titanic.head()\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "c3272fb4-3019-418e-89b5-37ce1556210e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "29.69911764705882 +/- 14.526497332334042\n" + ] + } + ], + "source": [ + "print(titanic[\"Age\"].mean(), \"+/-\", titanic[\"Age\"].std())" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "ac4ed766-9a66-4894-9331-3b2cf779a8ae", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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qXv37kpOTtbpp5+uBx54QL7XWJ/U+bfQKMuXL7fsdru1ZMkS6z//+Y/185//3MrIyLBKSkpiXVoDr732mnXPPfdYL730kiXJevnll/3unzdvnpWenm6tWrXK+uCDD6zLLrvM6t69u1VZWRmbgn2MHj3aevrpp60PP/zQ2rp1q3XxxRdbXbt2tcrLy71tbr75Zis3N9cqKiqyNm7caJ199tnWOeecE8OqT3jllVes1atXWzt37rR27Nhh3X333VZiYqL14YcfWpZldu31bdiwwerWrZvVt29fa+rUqd7tJu/D7NmzrTPPPNPav3+/9/b111977ze59m+++cY67bTTrOuvv956//33rc8++8x6/fXXrd27d3vbmHzuWpZlHThwwG/u33jjDUuS9eabb1qWZfb8z5kzx8rKyrJeffVVa8+ePdbzzz9vpaamWo899pi3zcmcf0K6kYYMGWJNmjTJ+7PL5bI6d+5sFRYWxrCq8OqHtNvttjp27Gg9/PDD3m1HjhyxkpKSrGXLlsWgwtAOHDhgSbLefvtty7KO1ZqYmGg9//zz3jYff/yxJclav359rMoMKTMz0/rjH//YqmovKyuzevToYb3xxhvWueee6w1p0/dh9uzZVr9+/QLeZ3rt06dPt4YPHx70/tZ27lqWZU2dOtU6/fTTLbfbbfz8X3LJJdYNN9zgt+2KK66wrrnmGsuyTv7883F3I9TU1GjTpk0qKCjwbouPj1dBQYHWr18fw8oab8+ePSouLvbbl/T0dOXn5xu5L6WlpZKkU045RZK0adMm1dbW+tXfq1cvde3a1bj6XS6Xli9froqKCg0dOrRV1T5p0iRdcsklfrVKrWP+d+3apc6dO+u73/2urrnmGu3du1eS+bW/8sorGjRokH784x+rQ4cOGjBggJ566inv/a3t3K2pqdGf//xn3XDDDYqLizN+/s855xwVFRVp586dkqQPPvhA69at00UXXSTp5M9/m/uCjaY4ePCgXC6XcnJy/Lbn5OTok08+iVFV0SkuLpakgPviuc8Ubrdbt99+u4YNG6bevXtLOla/3W5XRkaGX1uT6t++fbuGDh2qqqoqpaam6uWXX9b3v/99bd261fjaJWn58uXavHmz/v3vfze4z/T5z8/P1zPPPKOePXtq//79+vWvf60RI0boww8/NL72zz77TE8++aSmTZumu+++W//+97912223yW63a/z48a3q3JWkVatW6ciRI7r++uslmX/szJgxQ06nU7169VJCQoJcLpfmzJmja665RtLJf+0kpGG8SZMm6cMPP9S6detiXUqj9OzZU1u3blVpaaleeOEFjR8/Xm+//Xasy4rIvn37NHXqVL3xxhtyOByxLqfRPO96JKlv377Kz8/XaaedppUrVyo5OTmGlYXndrs1aNAgzZ07V5I0YMAAffjhh1q0aJHGjx8f4+oab/HixbrooovUuXPnWJcSkZUrV+q5557T0qVLdeaZZ2rr1q26/fbb1blz55jMPx93N0J2drYSEhIaXIVYUlKijh07xqiq6HjqNX1fJk+erFdffVVvvvmm31eMduzYUTU1NTpy5Ihfe5Pqt9vtysvL08CBA1VYWKh+/frpscceaxW1b9q0SQcOHNBZZ50lm80mm82mt99+W48//rhsNptycnKM3wdfGRkZ+t73vqfdu3cbP/+dOnXS97//fb9tZ5xxhvfj+tZy7krS559/rn/84x+aOHGid5vp83/XXXdpxowZ+ulPf6o+ffrouuuu0x133KHCwkJJJ3/+CelGsNvtGjhwoIqKirzb3G63ioqKNHTo0BhW1njdu3dXx44d/fbF6XTq/fffN2JfLMvS5MmT9fLLL2vt2rXq3r273/0DBw5UYmKiX/07duzQ3r17jag/ELfbrerq6lZR+/nnn6/t27dr69at3tugQYN0zTXXeP/f9H3wVV5erk8//VSdOnUyfv6HDRvWYLnhzp07ddppp0ky/9z19fTTT6tDhw665JJLvNtMn/+jR48qPt4/GhMSEuR2uyXFYP6b/VK0b7nly5dbSUlJ1jPPPGN99NFH1k033WRlZGRYxcXFsS6tgbKyMmvLli3Wli1bLEnWo48+am3ZssX6/PPPLcs6towgIyPD+stf/mJt27bN+tGPfmTMMo5bbrnFSk9Pt9566y2/pRxHjx71trn55putrl27WmvXrrU2btxoDR061Bo6dGgMqz5hxowZ1ttvv23t2bPH2rZtmzVjxgwrLi7O+vvf/25Zltm1B+N7dbdlmb0Pd955p/XWW29Ze/bssd577z2roKDAys7Otg4cOGBZltm1b9iwwbLZbNacOXOsXbt2Wc8995yVkpJi/fnPf/a2Mfnc9XC5XFbXrl2t6dOnN7jP5PkfP3681aVLF+8SrJdeesnKzs62fvWrX3nbnMz5J6Sj8MQTT1hdu3a17Ha7NWTIEOtf//pXrEsK6M0337QkNbiNHz/esqxjSwnuu+8+Kycnx0pKSrLOP/98a8eOHbEt+rhAdUuynn76aW+byspK69Zbb7UyMzOtlJQU6/LLL7f2798fu6J93HDDDdZpp51m2e1269RTT7XOP/98b0Bbltm1B1M/pE3eh7Fjx1qdOnWy7Ha71aVLF2vs2LF+64xNrt2yLOuvf/2r1bt3byspKcnq1auX9Yc//MHvfpPPXY/XX3/dkhSwLpPn3+l0WlOnTrW6du1qORwO67vf/a51zz33WNXV1d42J3P++apKAAAMxe+kAQAwFCENAIChCGkAAAxFSAMAYChCGgAAQxHSAAAYipAGAMBQhDQAAIYipAEAMBQhDcBr/fr1SkhI8PtCBACxw58FBeA1ceJEpaamavHixdqxY0er+Q5g4NuKd9IAJB37OscVK1bolltu0SWXXKJnnnnG7/5XXnlFPXr0kMPh0KhRo/Tss88qLi7O73uB161bpxEjRig5OVm5ubm67bbbVFFRcXJ3BPgWIaQBSJJWrlypXr16qWfPnrr22mu1ZMkSeT5o27Nnj6666iqNGTNGH3zwgX7xi1/onnvu8ev/6aef6sILL9SVV16pbdu2acWKFVq3bp0mT54ci90BvhX4uBuAJGnYsGH6yU9+oqlTp6qurk6dOnXS888/r//5n//RjBkztHr1am3fvt3b/t5779WcOXN0+PBhZWRkaOLEiUpISNDvf/97b5t169bp3HPPVUVFhRwORyx2C2jVeCcNQDt27NCGDRt09dVXS5JsNpvGjh2rxYsXe+8fPHiwX58hQ4b4/fzBBx/omWeeUWpqqvc2evRoud1u7dmz5+TsCPAtY4t1AQBib/Hixaqrq/O7UMyyLCUlJWnBggURjVFeXq5f/OIXuu222xrc17Vr12arFWhLCGmgjaurq9Of/vQnPfLII7rgggv87hszZoyWLVumnj176rXXXvO779///rffz2eddZY++ugj5eXltXjNQFvB76SBNm7VqlUaO3asDhw4oPT0dL/7pk+frrVr12rlypXq2bOn7rjjDt14443aunWr7rzzTn3xxRc6cuSI0tPTtW3bNp199tm64YYbNHHiRLVr104fffSR3njjjYjfjQPwx++kgTZu8eLFKigoaBDQknTllVdq48aNKisr0wsvvKCXXnpJffv21ZNPPum9ujspKUmS1LdvX7399tvauXOnRowYoQEDBmjWrFmstQaagHfSAKIyZ84cLVq0SPv27Yt1KcC3Fr+TBhCR3/3udxo8eLCysrL03nvv6eGHH2YNNNDCCGkAEdm1a5cefPBBffPNN+ratavuvPNOzZw5M9ZlAd9qfNwNAIChuHAMAABDEdIAABiKkAYAwFCENAAAhiKkAQAwFCENAIChCGkAAAxFSAMAYKj/D51rLY0XPcSTAAAAAElFTkSuQmCC\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Create a visualization\n", + "sns.relplot(\n", + " data=titanic,\n", + " x=\"Age\", y=\"Survived\", \n", + " # col=\"Sex\",\n", + " # hue=\"smoker\", \n", + " # style=\"smoker\",\n", + " # size=\"size\",\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3f2ff08c-8c8c-4e7c-944a-8acb5c02746b", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pywhy-discover", + "language": "python", + "name": "pywhy-discover" + }, + "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.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/api.rst b/doc/api.rst index 2447772f2..5174d44e2 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -109,20 +109,6 @@ learning. ClassifierCMITest CategoricalCITest -Conditional k-sample testing -============================ - -Testing for conditional discrepancy among variables is a core part -of many causal inference procedures, such as constraint-based structure -learning. - -.. currentmodule:: dodiscover.cd -.. autosummary:: - :toctree: generated/ - - BaseConditionalDiscrepancyTest - KernelCDTest - BregmanCDTest Utilities ========= diff --git a/doc/tutorials/markovian/Untitled.ipynb b/doc/tutorials/markovian/Untitled.ipynb new file mode 100644 index 000000000..363fcab7e --- /dev/null +++ b/doc/tutorials/markovian/Untitled.ipynb @@ -0,0 +1,6 @@ +{ + "cells": [], + "metadata": {}, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/tutorials/multi-domain/basic-multidomain-chain-model.ipynb b/doc/tutorials/multi-domain/basic-multidomain-chain-model.ipynb new file mode 100644 index 000000000..941770305 --- /dev/null +++ b/doc/tutorials/multi-domain/basic-multidomain-chain-model.ipynb @@ -0,0 +1,1873 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "743325c9-7fe4-403e-92cc-68fafecf4cad", + "metadata": {}, + "source": [ + "# Multi-domain 2-Chain Graph Analysis with I-FCI vs $\\Psi$-FCI\n", + "\n", + "Let there be two domains to consider in our following example. For concreteness, let us follow the story of the paper, and say these are two different laboratory settings, where two proteins are sequenced. We wish to discover the cause-and-effect relationship between these two proteins. \n", + "\n", + "Here, we analyze the 2-chain selection diagram: $X \\leftarrow Y \\leftarrow S_0^{1,2}$ with two interventional distributions that occur over domains 1 and 2. $S_0^{1,2}$ is an S-node representing a possible difference in mechanism for node Y between domains 1 and 2.\n", + "\n", + "- X is protein 1\n", + "- Y is protein 2\n", + "- $S_0^{1,2}$ represents a change in laboratory settings that induce changes in the protein 2 expression levels\n", + "\n", + "Experiments are done by perturbing protein 1 (X) and then measuring the protein expression levels of X and Y." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "5c8d3bd1-e720-43e3-b2f3-82eef12c14ea", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + "%load_ext lab_black" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "476e1a05-5c57-4c04-a4ad-22e835df8290", + "metadata": {}, + "outputs": [], + "source": [ + "from IPython.display import display_svg" + ] + }, + { + "cell_type": "code", + "execution_count": 114, + "id": "bba338b9-78f2-498a-815b-70c1d348d174", + "metadata": {}, + "outputs": [], + "source": [ + "from pprint import pprint\n", + "import numpy as np\n", + "import scipy\n", + "import pandas as pd\n", + "import collections\n", + "from itertools import combinations\n", + "import networkx as nx\n", + "import pywhy_graphs as pgraphs\n", + "from pywhy_graphs.functional import (\n", + " make_graph_linear_gaussian,\n", + " make_graph_multidomain,\n", + " set_node_attributes_with_G,\n", + " apply_linear_soft_intervention,\n", + " sample_multidomain_lin_functions,\n", + ")\n", + "from pywhy_graphs.viz import draw\n", + "from pywhy_graphs.simulate import simulate_random_er_dag\n", + "\n", + "from dodiscover.cd import KernelCDTest\n", + "from dodiscover.ci import KernelCITest, FisherZCITest, Oracle\n", + "from dodiscover.constraint.skeleton import LearnMultiDomainSkeleton\n", + "from dodiscover.constraint.utils import dummy_sample\n", + "from dodiscover.datasets import sample_from_graph\n", + "\n", + "from dodiscover import (\n", + " SFCI,\n", + " PsiFCI,\n", + " FCI,\n", + " Context,\n", + " make_context,\n", + " InterventionalContextBuilder,\n", + ")\n", + "from dodiscover.metrics import structure_hamming_dist, confusion_matrix_networks\n", + "\n", + "import seaborn as sns\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "c71ac0fe-354e-4adc-a3a1-7fd21c255eb4", + "metadata": {}, + "outputs": [], + "source": [ + "seed = 12345" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "3cb5db22-3ce1-490f-b44e-f136e53d8247", + "metadata": {}, + "outputs": [], + "source": [ + "alpha = 0.05" + ] + }, + { + "cell_type": "markdown", + "id": "75383db8-5cc7-4e6e-8c8e-b395a43f6690", + "metadata": { + "tags": [] + }, + "source": [ + "## Setup Linear Functional Graph" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "529a4be3-faa7-4460-bdab-bcb59100fcdc", + "metadata": {}, + "outputs": [], + "source": [ + "node_mean_lims = [-1, 1]\n", + "node_std_lims = [0.5, 1.5]\n", + "edge_functions = [lambda x: x, lambda x: x**2]\n", + "edge_weight_lims = [-1, 1]\n", + "\n", + "n_domains = 2\n", + "n_samples = 5000" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "689a27e4-f21d-4572-a4b8-34b3c4c626ad", + "metadata": {}, + "outputs": [], + "source": [ + "directed_edges = [(\"y\", \"x\")]\n", + "\n", + "graph = pgraphs.AugmentedGraph(\n", + " incoming_directed_edges=directed_edges,\n", + ")\n", + "\n", + "int_graph = graph.copy()\n", + "int_graph.add_f_node({\"x\"}, domain=1)\n", + "\n", + "aug_graph = int_graph.copy()\n", + "aug_graph.add_f_node({\"x\"}, domain=2, require_unique=False)\n", + "aug_graph.add_s_node((1, 2), {\"y\"})" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "5f9cfa6f-bfab-43cc-9677-c911ab6b595e", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Causal Diagram\n", + "\n", + "\n", + "y\n", + "\n", + "y\n", + "\n", + "\n", + "\n", + "x\n", + "\n", + "x\n", + "\n", + "\n", + "\n", + "y->x\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "G = draw(graph, name=\"Causal Diagram\")\n", + "G.render(\n", + " outfile=\"./two-chain-single-domain.pdf\",\n", + " format=\"pdf\",\n", + " cleanup=True,\n", + ")\n", + "display_svg(G)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "cb5ebc4a-16ad-4d6c-9003-b95d492a1396", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Augmented Causal Diagram\n", + "\n", + "\n", + "y\n", + "\n", + "y\n", + "\n", + "\n", + "\n", + "x\n", + "\n", + "x\n", + "\n", + "\n", + "\n", + "y->x\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 0)\n", + "\n", + "('F', 0)\n", + "\n", + "\n", + "\n", + "('F', 0)->x\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "G = draw(int_graph, name=\"Augmented Causal Diagram\")\n", + "G.render(\n", + " outfile=\"./two-chain-single-domain-with-int.pdf\",\n", + " format=\"pdf\",\n", + " cleanup=True,\n", + ")\n", + "display_svg(G)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "36bd044f-a331-403d-91bc-e1be604e85d0", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Augmented Selection Diagram\n", + "\n", + "\n", + "y\n", + "\n", + "y\n", + "\n", + "\n", + "\n", + "x\n", + "\n", + "x\n", + "\n", + "\n", + "\n", + "y->x\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('S', 0)\n", + "\n", + "('S', 0)\n", + "\n", + "\n", + "\n", + "('S', 0)->y\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 0)\n", + "\n", + "('F', 0)\n", + "\n", + "\n", + "\n", + "('F', 0)->x\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 1)\n", + "\n", + "('F', 1)\n", + "\n", + "\n", + "\n", + "('F', 1)->x\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "G = draw(aug_graph, name=\"Augmented Selection Diagram\")\n", + "G.render(\n", + " outfile=\"./two-chain-multi-domain-with-int.pdf\",\n", + " format=\"pdf\",\n", + " cleanup=True,\n", + ")\n", + "display_svg(G)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "3f36b57d-66dc-428f-a6db-0c62dc15caba", + "metadata": {}, + "outputs": [], + "source": [ + "# convert graph to linear functional graph\n", + "lin_graph = make_graph_linear_gaussian(\n", + " graph,\n", + " node_mean_lims=node_mean_lims,\n", + " node_std_lims=node_std_lims,\n", + " edge_functions=edge_functions,\n", + " edge_weight_lims=edge_weight_lims,\n", + " random_state=seed,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "7eb41b8d-f4fc-4c1f-86ea-b0e8961abe3f", + "metadata": {}, + "outputs": [], + "source": [ + "# convert graph to linear functional graph\n", + "lin_graph = make_graph_linear_gaussian(\n", + " graph,\n", + " node_mean_lims=node_mean_lims,\n", + " node_std_lims=node_std_lims,\n", + " edge_functions=edge_functions,\n", + " edge_weight_lims=edge_weight_lims,\n", + " random_state=seed,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "aca6ed01-f47e-41d5-9c49-486115c3685a", + "metadata": {}, + "outputs": [], + "source": [ + "# convert graph to linear functional graph\n", + "aug_lin_graph = make_graph_linear_gaussian(\n", + " aug_graph,\n", + " node_mean_lims=node_mean_lims,\n", + " node_std_lims=node_std_lims,\n", + " edge_functions=edge_functions,\n", + " edge_weight_lims=edge_weight_lims,\n", + " random_state=seed,\n", + ")\n", + "md_lin_graph = sample_multidomain_lin_functions(\n", + " aug_lin_graph,\n", + " n_domains=n_domains,\n", + " node_mean_lims=node_mean_lims,\n", + " node_std_lims=node_std_lims,\n", + " edge_functions=edge_functions,\n", + " edge_weight_lims=edge_weight_lims,\n", + " random_state=seed,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "0f91d578-9fd1-4c7f-9b36-9cabd1afcda2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "('y',\n", + " {'gaussian_noise_function': {'mean': 0.19661750717437965,\n", + " 'std': 0.6867341856037134},\n", + " 'parent_functions': {('S', 0): {'func': at 0x148caa8b0>,\n", + " 'weight': -0.5453279550656607}}})\n", + "('x',\n", + " {'gaussian_noise_function': {'mean': 0.3455120880292426,\n", + " 'std': 1.441802865269937},\n", + " 'parent_functions': {'y': {'func': at 0x148caa8b0>,\n", + " 'weight': -0.5453279550656607},\n", + " ('F', 0): {'func': at 0x148caa940>,\n", + " 'weight': 0.5947309146654682},\n", + " ('F', 1): {'func': at 0x148caa8b0>,\n", + " 'weight': 0.3525093415019491}}})\n", + "(('F', 0),\n", + " {'gaussian_noise_function': {'mean': -0.5453279550656607,\n", + " 'std': 0.8167583397097529},\n", + " 'parent_functions': {}})\n", + "(('F', 1),\n", + " {'gaussian_noise_function': {'mean': 0.5947309146654682,\n", + " 'std': 1.1762546707509745},\n", + " 'parent_functions': {}})\n", + "(('S', 0),\n", + " {'domain_ids': (1, 2),\n", + " 'gaussian_noise_function': {'mean': -0.217780898796182,\n", + " 'std': 0.8328139278663845},\n", + " 'parent_functions': {}})\n" + ] + } + ], + "source": [ + "for node in aug_lin_graph.nodes(data=True):\n", + " pprint(node)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "85673523-2582-426b-b29a-37a2ce9be070", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "('y',\n", + " {'domain_gaussian_noise_function': {1: {'mean': -0.5453279550656607,\n", + " 'std': 0.8167583397097529},\n", + " 2: {'mean': 0.5947309146654682,\n", + " 'std': 1.1762546707509745}},\n", + " 'gaussian_noise_function': {'mean': 0.19661750717437965,\n", + " 'std': 0.6867341856037134},\n", + " 'invariant_domains': set(),\n", + " 'parent_functions': {('S', 0): {'func': at 0x148caa8b0>,\n", + " 'weight': -0.5453279550656607}}})\n", + "('x',\n", + " {'gaussian_noise_function': {'mean': 0.3455120880292426,\n", + " 'std': 1.441802865269937},\n", + " 'parent_functions': {'y': {'func': at 0x148caa8b0>,\n", + " 'weight': -0.5453279550656607},\n", + " ('F', 0): {'func': at 0x148caa940>,\n", + " 'weight': 0.5947309146654682},\n", + " ('F', 1): {'func': at 0x148caa8b0>,\n", + " 'weight': 0.3525093415019491}}})\n", + "(('F', 0),\n", + " {'gaussian_noise_function': {'mean': -0.5453279550656607,\n", + " 'std': 0.8167583397097529},\n", + " 'parent_functions': {}})\n", + "(('F', 1),\n", + " {'gaussian_noise_function': {'mean': 0.5947309146654682,\n", + " 'std': 1.1762546707509745},\n", + " 'parent_functions': {}})\n", + "(('S', 0),\n", + " {'domain_ids': (1, 2),\n", + " 'gaussian_noise_function': {'mean': -0.217780898796182,\n", + " 'std': 0.8328139278663845},\n", + " 'parent_functions': {}})\n" + ] + } + ], + "source": [ + "for node in md_lin_graph.nodes(data=True):\n", + " pprint(node)" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "4ccfafbf-da75-4cd5-a0da-a6baeaa956f3", + "metadata": {}, + "outputs": [], + "source": [ + "# example analysis\n", + "est = KernelCITest()\n", + "est = FisherZCITest()" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "8f8bbc3a-aba5-4977-976f-1776ab41944c", + "metadata": {}, + "outputs": [ + { + "ename": "NameError", + "evalue": "name 'data' is not defined", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m/var/folders/6_/sl83qtkd68x3_mvfys07_6qm0000gn/T/ipykernel_34343/1620990787.py\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0mest\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mtest\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;36m1\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m{\u001b[0m\u001b[0;34m\"x\"\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m{\u001b[0m\u001b[0;34m\"y\"\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m{\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", + "\u001b[0;31mNameError\u001b[0m: name 'data' is not defined" + ] + } + ], + "source": [ + "est.test(data[1], {\"x\"}, {\"y\"}, {})" + ] + }, + { + "cell_type": "code", + "execution_count": 91, + "id": "f9ece90e-27ac-488c-aa36-9fee3200f2c0", + "metadata": {}, + "outputs": [ + { + "ename": "SyntaxError", + "evalue": "invalid syntax (1201510190.py, line 1)", + "output_type": "error", + "traceback": [ + "\u001b[0;36m File \u001b[0;32m\"/var/folders/6_/sl83qtkd68x3_mvfys07_6qm0000gn/T/ipykernel_33752/1201510190.py\"\u001b[0;36m, line \u001b[0;32m1\u001b[0m\n\u001b[0;31m x_var =\u001b[0m\n\u001b[0m ^\u001b[0m\n\u001b[0;31mSyntaxError\u001b[0m\u001b[0;31m:\u001b[0m invalid syntax\n" + ] + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "ERROR:root:Cannot parse: 1:8: x_var = \n", + "Traceback (most recent call last):\n", + " File \"/Users/adam2392/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/lab_black.py\", line 218, in format_cell\n", + " formatted_code = _format_code(cell)\n", + " File \"/Users/adam2392/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/lab_black.py\", line 29, in _format_code\n", + " return format_str(src_contents=code, mode=FileMode())\n", + " File \"/Users/adam2392/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/black/__init__.py\", line 1073, in format_str\n", + " dst_contents = _format_str_once(src_contents, mode=mode)\n", + " File \"/Users/adam2392/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/black/__init__.py\", line 1083, in _format_str_once\n", + " src_node = lib2to3_parse(src_contents.lstrip(), mode.target_versions)\n", + " File \"/Users/adam2392/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/black/parsing.py\", line 127, in lib2to3_parse\n", + " raise exc from None\n", + "black.parsing.InvalidInput: Cannot parse: 1:8: x_var = \n" + ] + } + ], + "source": [ + "x_var = \n", + "# compute conditional independence test\n", + "# get the sigma-map for this F-node\n", + "distribution_idx = context.sigma_map[x_var]\n", + "\n", + "# get the distributions across the two distributions\n", + "data_i = data[distribution_idx[0]].copy()\n", + "data_j = data[distribution_idx[1]].copy()\n", + "\n", + "# name the group column the F-node, so Oracle works as expected\n", + "data_i[x_var] = 0\n", + "data_j[x_var] = 1\n", + "this_data = pd.concat((data_i, data_j), axis=0)" + ] + }, + { + "cell_type": "markdown", + "id": "c183daec-9f74-4489-a4bf-34f70a11620e", + "metadata": { + "tags": [] + }, + "source": [ + "## Sample Dataset" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "6b508e89-1fc8-4dc5-82e3-6557bd43bf87", + "metadata": {}, + "outputs": [], + "source": [ + "n_samples = 5000" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "2e343326-48f4-4451-8429-6ff77902664d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[('F', 0), ('F', 1), ('S', 0)]\n", + "{'x', 'y'}\n", + "{'directed': OutEdgeView([('y', 'x'), (('F', 0), 'x'), (('F', 1), 'x'), (('S', 0), 'y')]), 'bidirected': EdgeView([]), 'undirected': EdgeView([])}\n", + "[('F', 0), ('F', 1), ('S', 0), 'y', 'x']\n" + ] + } + ], + "source": [ + "print(lin_graph.augmented_nodes)\n", + "print(md_lin_graph.non_augmented_nodes)\n", + "print(md_lin_graph.edges())\n", + "print(list(nx.topological_sort(md_lin_graph.get_graphs(\"directed\"))))" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "id": "973c3136-9721-412e-a951-e4bd9d27c50f", + "metadata": {}, + "outputs": [], + "source": [ + "domain_indices = []\n", + "intervention_targets = []\n", + "mechanisms = []\n", + "data = []\n", + "\n", + "for idx, domain_id in enumerate(range(1, n_domains + 1)):\n", + " df = sample_from_graph(\n", + " md_lin_graph,\n", + " sample_func=\"multidomain\",\n", + " n_samples=n_samples,\n", + " n_jobs=1,\n", + " random_state=seed,\n", + " domain_id=domain_id,\n", + " )\n", + "\n", + " domain_indices.append(domain_id)\n", + " intervention_targets.append({\"x\"})\n", + " mechanisms.append(idx)\n", + " data.append(df)" + ] + }, + { + "cell_type": "markdown", + "id": "1b90d6e9-68fe-40c7-84c9-b9358fb9f38e", + "metadata": { + "tags": [] + }, + "source": [ + "# Oracle Analysis\n", + "\n", + "First, we compare what would happen with the oracle graph, when we learn using the ground-truth augmented selection diagram.\n", + "\n", + "We compare the FCI, $\\Psi$-FCI and S-FCI algorithms" + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "id": "0fcfdd27-8600-45cb-8b34-a12722411700", + "metadata": {}, + "outputs": [], + "source": [ + "context = make_context().variables(aug_graph.non_augmented_nodes).build()" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "6b3ed7a2-ccb0-4f8f-a885-58df23e6e636", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['x', 'y']\n" + ] + } + ], + "source": [ + "oracle = Oracle(graph)\n", + "dummy_df = dummy_sample(graph)\n", + "print(graph.nodes)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "0b5882d5-f2af-40d0-984a-598b38917560", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# now learn the relationships\n", + "oracle_learner = FCI(ci_estimator=oracle, alpha=alpha)\n", + "oracle_learner.fit(\n", + " dummy_df,\n", + " context,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "70e68d34-8725-4368-9c92-caa47fd1e0f2", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PAG\n", + "\n", + "\n", + "y\n", + "\n", + "y\n", + "\n", + "\n", + "\n", + "x\n", + "\n", + "x\n", + "\n", + "\n", + "\n", + "y->x\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fci_oracle_g = draw(oracle_learner.graph_, name=\"PAG\")\n", + "fci_oracle_g.render(\n", + " filename=\"./two-chain-fci-oracle\",\n", + " outfile=\"./two-chain-fci-oracle.pdf\",\n", + " format=\"pdf\",\n", + " cleanup=True,\n", + ")\n", + "display_svg(fci_oracle_g)" + ] + }, + { + "cell_type": "code", + "execution_count": 76, + "id": "c5d42695-407a-41fc-bce0-60cf7c95e887", + "metadata": {}, + "outputs": [], + "source": [ + "context = (\n", + " make_context(create_using=InterventionalContextBuilder)\n", + " .variables(aug_graph.non_augmented_nodes)\n", + " .obs_distribution(False)\n", + " .intervention_targets([(\"x\"), ()])\n", + " .num_distributions(2)\n", + " .build()\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 77, + "id": "a6fdd4c1-ddf6-4f2e-afe3-88712a5a7593", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 77, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# IFCI\n", + "oracle_learner = PsiFCI(\n", + " ci_estimator=oracle,\n", + " cd_estimator=oracle,\n", + " alpha=alpha,\n", + " known_intervention_targets=True,\n", + ")\n", + "oracle_learner.fit(\n", + " [dummy_df, dummy_df],\n", + " context,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 78, + "id": "2ec78a4a-f8e5-4c3b-8ab7-46ac38978d2a", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "I-PAG\n", + "\n", + "\n", + "('F', 0)\n", + "\n", + "('F', 0)\n", + "\n", + "\n", + "\n", + "y\n", + "\n", + "y\n", + "\n", + "\n", + "\n", + "('F', 0)->y\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "x\n", + "\n", + "x\n", + "\n", + "\n", + "\n", + "('F', 0)->x\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "x->y\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ifci_oracle_g = draw(oracle_learner.graph_, name=\"I-PAG\")\n", + "ifci_oracle_g.render(\n", + " filename=\"./two-chain-ifci-oracle\",\n", + " outfile=\"./two-chain-ifci-oracle.pdf\",\n", + " format=\"pdf\",\n", + " cleanup=True,\n", + ")\n", + "display_svg(ifci_oracle_g)" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "f0e8e089-aded-4476-a9f7-8f1f51742531", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# SFCI\n", + "oracle_learner = SFCI(ci_estimator=oracle, cd_estimator=oracle, alpha=alpha)\n", + "oracle_learner.fit(\n", + " [dummy_df, dummy_df],\n", + " context,\n", + " domain_indices=domain_indices,\n", + " intervention_targets=intervention_targets,\n", + " # debug=False\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "9be4b9c7-4073-4d2a-9496-a6a19f8658ec", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "S-PAG\n", + "\n", + "\n", + "y\n", + "\n", + "y\n", + "\n", + "\n", + "\n", + "x\n", + "\n", + "x\n", + "\n", + "\n", + "\n", + "y->x\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 0)\n", + "\n", + "('F', 0)\n", + "\n", + "\n", + "\n", + "('F', 0)->y\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 0)->x\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('S', 0)\n", + "\n", + "('S', 0)\n", + "\n", + "\n", + "\n", + "('S', 0)->y\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('S', 0)->x\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "sfci_oracle_g = draw(oracle_learner.graph_, name=\"S-PAG\")\n", + "sfci_oracle_g.render(\n", + " filename=\"./two-chain-sfci-oracle\",\n", + " outfile=\"./two-chain-sfci-oracle.pdf\",\n", + " format=\"pdf\",\n", + " cleanup=True,\n", + ")\n", + "display_svg(sfci_oracle_g)" + ] + }, + { + "cell_type": "markdown", + "id": "15a2b720-1742-414b-82a9-6d75048df390", + "metadata": {}, + "source": [ + "# Generated Data Analysis" + ] + }, + { + "cell_type": "markdown", + "id": "6faffed0-11cb-4695-860e-55c9d622d015", + "metadata": {}, + "source": [ + "## With FCI" + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "id": "b81823ce-07dd-42b8-8692-869a4fd9cc87", + "metadata": {}, + "outputs": [], + "source": [ + "context = make_context().variables(aug_graph.non_augmented_nodes).build()" + ] + }, + { + "cell_type": "code", + "execution_count": 45, + "id": "074c3b07-cbcd-48d6-8d72-d2e636684d86", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 45, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# now learn the relationships\n", + "learner = FCI(ci_estimator=FisherZCITest(), alpha=alpha)\n", + "learner.fit(\n", + " pd.concat(data, axis=0),\n", + " context,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 46, + "id": "3ce0cfc1-8a1c-424d-b2e2-7aacbe2db63c", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PAG\n", + "\n", + "\n", + "x\n", + "\n", + "x\n", + "\n", + "\n", + "\n", + "y\n", + "\n", + "y\n", + "\n", + "\n", + "\n", + "x->y\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fci_g = draw(learner.graph_, name=\"PAG\")\n", + "fci_g.render(\n", + " filename=\"./two-chain-fci\",\n", + " outfile=\"./two-chain-fci.pdf\",\n", + " format=\"pdf\",\n", + " cleanup=True,\n", + ")\n", + "display_svg(fci_g)" + ] + }, + { + "cell_type": "markdown", + "id": "ea06f144-0435-40e2-89ad-4b4b2513224f", + "metadata": {}, + "source": [ + "## With I-FCI" + ] + }, + { + "cell_type": "code", + "execution_count": 56, + "id": "62ce26bc-86c8-4a28-835c-2581ecd316b6", + "metadata": {}, + "outputs": [], + "source": [ + "context = (\n", + " make_context(create_using=InterventionalContextBuilder)\n", + " .variables(aug_graph.non_augmented_nodes)\n", + " .obs_distribution(False)\n", + " .intervention_targets([(\"x\"), (\"x\")])\n", + " .mechanisms([{\"x\": 1}, {\"x\": 2}])\n", + " .num_distributions(2)\n", + " .build()\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 57, + "id": "04b7843f-2ebf-4f65-8a04-4cff79050249", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 57, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# IFCI\n", + "learner = PsiFCI(\n", + " ci_estimator=FisherZCITest(),\n", + " cd_estimator=KernelCDTest(),\n", + " alpha=alpha,\n", + " known_intervention_targets=True,\n", + ")\n", + "learner.fit(\n", + " data,\n", + " context,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 58, + "id": "467032f4-447b-404f-a9b7-c36c628064a6", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "I-PAG\n", + "\n", + "\n", + "('F', 0)\n", + "\n", + "('F', 0)\n", + "\n", + "\n", + "\n", + "x\n", + "\n", + "x\n", + "\n", + "\n", + "\n", + "('F', 0)->x\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "y\n", + "\n", + "y\n", + "\n", + "\n", + "\n", + "('F', 0)->y\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "x->y\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ifci_g = draw(learner.graph_, name=\"I-PAG\")\n", + "ifci_g.render(\n", + " filename=\"./two-chain-ifci-oracle\",\n", + " outfile=\"./two-chain-ifci-oracle.pdf\",\n", + " format=\"pdf\",\n", + " cleanup=True,\n", + ")\n", + "display_svg(ifci_g)" + ] + }, + { + "cell_type": "markdown", + "id": "80d98d9e-a8dd-4072-9209-5e4f343b49dc", + "metadata": {}, + "source": [ + "## Correct Answer with S-FCI" + ] + }, + { + "cell_type": "code", + "execution_count": 39, + "id": "7808fbd4-0fd3-4392-9c82-6351e5c35e09", + "metadata": {}, + "outputs": [], + "source": [ + "context = (\n", + " make_context(create_using=InterventionalContextBuilder)\n", + " .variables(aug_graph.non_augmented_nodes)\n", + " .num_distributions(2)\n", + " .build()\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "id": "824bfe3a-e5b8-43b7-9dfd-ae3e931cf802", + "metadata": {}, + "outputs": [], + "source": [ + "# now learn the relationships\n", + "learner = SFCI(\n", + " ci_estimator=FisherZCITest(), cd_estimator=KernelCDTest(), alpha=alpha, debug=True\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 41, + "id": "1a406382-3ca0-4b9f-8f1e-2aa2037f7c79", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Comparing x-y | set() : 0.0\n", + "Trying to learn skeleton for 1 and 2 to remove F-nodes: [('F', 0)] grouped with S-node: ('S', 0)\n", + "Comparing {('S', 0), ('F', 0)}-x | set() : 0.0\n", + "Comparing {('S', 0), ('F', 0)}-y | set() : 0.0\n", + "Comparing {('S', 0), ('F', 0)}-x | {'y'} : 0.0\n", + "Comparing {('S', 0), ('F', 0)}-y | {'x'} : 0.0\n", + "[('F', 0)]\n", + "[]\n", + "{('F', 0): frozenset()}\n", + "{('F', 0): [0, 1]}\n", + "Trying to learn skeleton for 1 to remove F-nodes: []\n", + "Trying to learn skeleton for 2 to remove F-nodes: []\n" + ] + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 41, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "learner.fit(\n", + " data,\n", + " context,\n", + " domain_indices=domain_indices,\n", + " intervention_targets=intervention_targets,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 42, + "id": "f7aeb8c0-03f6-4fe4-95fc-fb29f1abe477", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "x\n", + "\n", + "x\n", + "\n", + "\n", + "\n", + "y\n", + "\n", + "y\n", + "\n", + "\n", + "\n", + "x->y\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 0)\n", + "\n", + "('F', 0)\n", + "\n", + "\n", + "\n", + "('F', 0)->x\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 0)->y\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('S', 0)\n", + "\n", + "('S', 0)\n", + "\n", + "\n", + "\n", + "('S', 0)->x\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('S', 0)->y\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 42, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "draw(learner.graph_)" + ] + }, + { + "cell_type": "code", + "execution_count": 43, + "id": "cca33f1f-2311-4355-8510-29b1897956fb", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "defaultdict(. at 0x148d275e0>,\n", + " {('S', 0): defaultdict(, {('F', 0): []})})\n" + ] + } + ], + "source": [ + "pprint(learner.separating_sets_)" + ] + }, + { + "cell_type": "code", + "execution_count": 106, + "id": "e07a008a-b117-4d4b-8aad-551cafcf8f20", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[[0.88338169 0.11661831]\n", + " [0.95437093 0.04562907]\n", + " [0.92598123 0.07401877]\n", + " [0.74032007 0.25967993]\n", + " [0.74200266 0.25799734]\n", + " [0.89661462 0.10338538]\n", + " [0.54642251 0.45357749]\n", + " [0.94539668 0.05460332]\n", + " [0.67546416 0.32453584]\n", + " [0.75045264 0.24954736]\n", + " [0.89957344 0.10042656]\n", + " [0.85249165 0.14750835]\n", + " [0.81894958 0.18105042]\n", + " [0.84897506 0.15102494]\n", + " [0.86968602 0.13031398]\n", + " [0.96352565 0.03647435]\n", + " [0.81842388 0.18157612]\n", + " [0.80927313 0.19072687]\n", + " [0.58526745 0.41473255]\n", + " [0.84046682 0.15953318]]\n", + "[0.81835199 0.18164801]\n", + "0.8333333333333334\n", + "0.16666666666666666\n" + ] + } + ], + "source": [ + "rng = np.random.default_rng(seed)\n", + "test = rng.dirichlet((10, 2), 20)\n", + "print(test)\n", + "print(test.mean(axis=0))\n", + "\n", + "print(10 / 12)\n", + "print(2 / 12)" + ] + }, + { + "cell_type": "code", + "execution_count": 120, + "id": "5ea22be0-3a4d-4b22-a52c-5153c4c67569", + "metadata": {}, + "outputs": [], + "source": [ + "def convert_md_data_to_sd(data, domain_indices, intervention_targets):\n", + " # generate single-domain data\n", + " single_domain_data = []\n", + " single_domain_targets = []\n", + "\n", + " seen_indices = set()\n", + " for targets in intervention_targets:\n", + " indices = [\n", + " idx\n", + " for idx in range(len(domain_indices))\n", + " if intervention_targets[idx] == targets\n", + " ]\n", + " if any(idx in seen_indices for idx in indices):\n", + " continue\n", + " for idx in indices:\n", + " seen_indices.add(idx)\n", + "\n", + " single_domain_data.append(pd.concat([data[idx] for idx in indices], axis=0))\n", + " if targets == {}:\n", + " continue\n", + " single_domain_targets.append(targets)\n", + " return single_domain_data, single_domain_targets" + ] + }, + { + "cell_type": "markdown", + "id": "4d243b6a-13e4-4cb0-80fc-f053074e22a0", + "metadata": {}, + "source": [ + "# Large-scale random graph analysis\n", + "\n", + "Now, we instantiate a large number of functions over the two-chain graph setup we have to determine if this is just a function of the specific data setup we have. We demonstrate that in fact, consistently, I-FCI gets the wrong answer as is shown in the oracle setting across any function, noise parametrization, weight, or number of samples in our parameter grid.\n", + "\n", + "Our metric that we measure the performance of the algorithsm is the 1-0 loss. It is 0 if the Y->X is oriented as X o-o Y, or correctly and 1 otherwise." + ] + }, + { + "cell_type": "code", + "execution_count": 118, + "id": "aab3dcb4-c871-4a38-a36e-4cf41c3fdf6c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[ 5 6 7 8 9 10 11 12 13 14]\n", + "[0.1 0.175 0.25 0.325 0.4 ]\n" + ] + } + ], + "source": [ + "node_mean_lims = [-5, 5]\n", + "node_std_lims = [0.01, 1.5]\n", + "edge_functions = [lambda x: x, lambda x: x**2, lambda x: np.sin(x), lambda x: -x]\n", + "edge_weight_lims = [-5, 5]\n", + "n_node_grid = np.arange(5, 15)\n", + "p_grid = np.linspace(0.1, 0.4, 5)\n", + "n_domains_grid = np.arange(2, 10)\n", + "n_repeats = 5\n", + "\n", + "n_samples = 1000\n", + "ratio_interventions = 0.2\n", + "\n", + "print(n_node_grid)\n", + "print(p_grid)" + ] + }, + { + "cell_type": "code", + "execution_count": 123, + "id": "4d5aaeef-b4c7-4500-832a-6e9e4d893b6c", + "metadata": { + "collapsed": true, + "jupyter": { + "outputs_hidden": true + }, + "tags": [] + }, + "outputs": [ + { + "ename": "KeyboardInterrupt", + "evalue": "", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mKeyboardInterrupt\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m/var/folders/6_/sl83qtkd68x3_mvfys07_6qm0000gn/T/ipykernel_34343/3255049111.py\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 92\u001b[0m \u001b[0mdebug\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mFalse\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 93\u001b[0m )\n\u001b[0;32m---> 94\u001b[0;31m learner.fit(\n\u001b[0m\u001b[1;32m 95\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 96\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/sfcialg.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, data, context, domain_indices, intervention_targets)\u001b[0m\n\u001b[1;32m 115\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mintervention_targets\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mintervention_targets\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 116\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 117\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 118\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 119\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m_apply_rule11\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mgraph\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mEquivalenceClass\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mContext\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m->\u001b[0m \u001b[0mTuple\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mbool\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mList\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/intervention.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, data, context)\u001b[0m\n\u001b[1;32m 187\u001b[0m )\n\u001b[1;32m 188\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 189\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 190\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 191\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m_apply_rule11\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mgraph\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mEquivalenceClass\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mContext\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m->\u001b[0m \u001b[0mTuple\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mbool\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mList\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/_classes.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, data, context)\u001b[0m\n\u001b[1;32m 215\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 216\u001b[0m \u001b[0;31m# learn skeleton graph and the separating sets per variable\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 217\u001b[0;31m graph, self.separating_sets_ = self.learn_skeleton(\n\u001b[0m\u001b[1;32m 218\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcontext_\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mseparating_sets_\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 219\u001b[0m )\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/sfcialg.py\u001b[0m in \u001b[0;36mlearn_skeleton\u001b[0;34m(self, data, context, sep_set)\u001b[0m\n\u001b[1;32m 72\u001b[0m \u001b[0mn_jobs\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mn_jobs\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 73\u001b[0m )\n\u001b[0;32m---> 74\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mskeleton_learner_\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdomain_indices\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mintervention_targets\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdebug\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdebug\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 75\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 76\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcontext_\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mskeleton_learner_\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcontext_\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcopy\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/skeleton.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, data, context, domain_indices, intervention_targets, check_input, debug)\u001b[0m\n\u001b[1;32m 1904\u001b[0m \u001b[0mprint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34mf'Trying to learn skeleton for {source} to remove F-nodes: {source_fnodes}'\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1905\u001b[0m \u001b[0;31m# apply algorithm to learn skeleton among the F-node subgraph within a single domain\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1906\u001b[0;31m self._learn_skeleton(\n\u001b[0m\u001b[1;32m 1907\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1908\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mcontext\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/skeleton.py\u001b[0m in \u001b[0;36m_learn_skeleton\u001b[0;34m(self, data, context, condsel_method, conditional_test_func, possible_x_nodes, skipped_y_nodes, skipped_z_nodes, cross_distribution_test, group_with_snode, debug)\u001b[0m\n\u001b[1;32m 505\u001b[0m \u001b[0mx_var\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m{\u001b[0m\u001b[0mx_var\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mgroup_with_snode\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 506\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 507\u001b[0;31m test_stat, pvalue = self.evaluate_edge(\n\u001b[0m\u001b[1;32m 508\u001b[0m \u001b[0mthis_data\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mconditional_test_func\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mx_var\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0my_var\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mcond_set\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 509\u001b[0m )\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/skeleton.py\u001b[0m in \u001b[0;36mevaluate_edge\u001b[0;34m(self, data, conditional_test_func, X, Y, Z)\u001b[0m\n\u001b[1;32m 771\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mZ\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 772\u001b[0m \u001b[0mZ\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 773\u001b[0;31m \u001b[0mtest_stat\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mpvalue\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mconditional_test_func\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mtest\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m{\u001b[0m\u001b[0mX\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m{\u001b[0m\u001b[0mY\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mZ\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 774\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mn_ci_tests\u001b[0m \u001b[0;34m+=\u001b[0m \u001b[0;36m1\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 775\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mtest_stat\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mpvalue\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/cd/kernel_test.py\u001b[0m in \u001b[0;36mtest\u001b[0;34m(self, df, group_col, y_vars, x_vars)\u001b[0m\n\u001b[1;32m 136\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 137\u001b[0m \u001b[0;31m# now compute null distribution\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 138\u001b[0;31m null_dist = self.compute_null(\n\u001b[0m\u001b[1;32m 139\u001b[0m \u001b[0me_hat\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mK\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mL\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mnull_reps\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mnull_reps\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mrandom_state\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mrandom_state\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 140\u001b[0m )\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/cd/base.py\u001b[0m in \u001b[0;36mcompute_null\u001b[0;34m(self, e_hat, X, Y, null_reps, random_state)\u001b[0m\n\u001b[1;32m 166\u001b[0m \u001b[0;31m# dataset, where each group label is resampled for each sample\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 167\u001b[0m \u001b[0;31m# according to its propensity score\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 168\u001b[0;31m null_dist = Parallel(n_jobs=self.n_jobs)(\n\u001b[0m\u001b[1;32m 169\u001b[0m [\n\u001b[1;32m 170\u001b[0m \u001b[0mdelayed\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_statistic\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mX\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mY\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mrng\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mbinomial\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;36m1\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0me_hat\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0msize\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mn_samps\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/parallel.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(self, iterable)\u001b[0m\n\u001b[1;32m 1086\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_iterating\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_original_iterator\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1087\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1088\u001b[0;31m \u001b[0;32mwhile\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdispatch_one_batch\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0miterator\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1089\u001b[0m \u001b[0;32mpass\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1090\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/parallel.py\u001b[0m in \u001b[0;36mdispatch_one_batch\u001b[0;34m(self, iterator)\u001b[0m\n\u001b[1;32m 899\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0;32mFalse\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 900\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 901\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_dispatch\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtasks\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 902\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0;32mTrue\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 903\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/parallel.py\u001b[0m in \u001b[0;36m_dispatch\u001b[0;34m(self, batch)\u001b[0m\n\u001b[1;32m 817\u001b[0m \u001b[0;32mwith\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_lock\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 818\u001b[0m \u001b[0mjob_idx\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mlen\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_jobs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 819\u001b[0;31m \u001b[0mjob\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_backend\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mapply_async\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mbatch\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcallback\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mcb\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 820\u001b[0m \u001b[0;31m# A job can complete so quickly than its callback is\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 821\u001b[0m \u001b[0;31m# called before we get here, causing self._jobs to\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/_parallel_backends.py\u001b[0m in \u001b[0;36mapply_async\u001b[0;34m(self, func, callback)\u001b[0m\n\u001b[1;32m 206\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0mapply_async\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mfunc\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcallback\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mNone\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 207\u001b[0m \u001b[0;34m\"\"\"Schedule a func to be run\"\"\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 208\u001b[0;31m \u001b[0mresult\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mImmediateResult\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mfunc\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 209\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mcallback\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 210\u001b[0m \u001b[0mcallback\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mresult\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/_parallel_backends.py\u001b[0m in \u001b[0;36m__init__\u001b[0;34m(self, batch)\u001b[0m\n\u001b[1;32m 595\u001b[0m \u001b[0;31m# Don't delay the application, to avoid keeping the input\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 596\u001b[0m \u001b[0;31m# arguments in memory\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 597\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mresults\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mbatch\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 598\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 599\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0mget\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/parallel.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 286\u001b[0m \u001b[0;31m# change the default number of processes to -1\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 287\u001b[0m \u001b[0;32mwith\u001b[0m \u001b[0mparallel_backend\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_backend\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mn_jobs\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_n_jobs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 288\u001b[0;31m return [func(*args, **kwargs)\n\u001b[0m\u001b[1;32m 289\u001b[0m for func, args, kwargs in self.items]\n\u001b[1;32m 290\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/parallel.py\u001b[0m in \u001b[0;36m\u001b[0;34m(.0)\u001b[0m\n\u001b[1;32m 286\u001b[0m \u001b[0;31m# change the default number of processes to -1\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 287\u001b[0m \u001b[0;32mwith\u001b[0m \u001b[0mparallel_backend\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_backend\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mn_jobs\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_n_jobs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 288\u001b[0;31m return [func(*args, **kwargs)\n\u001b[0m\u001b[1;32m 289\u001b[0m for func, args, kwargs in self.items]\n\u001b[1;32m 290\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/cd/kernel_test.py\u001b[0m in \u001b[0;36m_statistic\u001b[0;34m(self, K, L, group_ind)\u001b[0m\n\u001b[1;32m 149\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 150\u001b[0m \u001b[0;31m# compute W matrices from K and z\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 151\u001b[0;31m \u001b[0mW0\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mW1\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_compute_inverse_kernel\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mK\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mgroup_ind\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 152\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 153\u001b[0m \u001b[0;31m# compute L kernels\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/cd/kernel_test.py\u001b[0m in \u001b[0;36m_compute_inverse_kernel\u001b[0;34m(self, K, z)\u001b[0m\n\u001b[1;32m 215\u001b[0m \u001b[0mn1\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mnp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msum\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mz\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 216\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 217\u001b[0;31m \u001b[0mW0\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mnp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mlinalg\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0minv\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mK0\u001b[0m \u001b[0;34m+\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mregs_\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;36m0\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m*\u001b[0m \u001b[0mnp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0midentity\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mn0\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 218\u001b[0m \u001b[0mW1\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mnp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mlinalg\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0minv\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mK1\u001b[0m \u001b[0;34m+\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mregs_\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;36m1\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m*\u001b[0m \u001b[0mnp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0midentity\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mn1\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 219\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mW0\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mW1\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/numpy/core/overrides.py\u001b[0m in \u001b[0;36minv\u001b[0;34m(*args, **kwargs)\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/numpy/linalg/linalg.py\u001b[0m in \u001b[0;36minv\u001b[0;34m(a)\u001b[0m\n\u001b[1;32m 536\u001b[0m \u001b[0msignature\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m'D->D'\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0misComplexType\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mt\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;32melse\u001b[0m \u001b[0;34m'd->d'\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 537\u001b[0m \u001b[0mextobj\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mget_linalg_error_extobj\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0m_raise_linalgerror_singular\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 538\u001b[0;31m \u001b[0mainv\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0m_umath_linalg\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0minv\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0ma\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0msignature\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0msignature\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mextobj\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mextobj\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 539\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mwrap\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mainv\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mastype\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mresult_t\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcopy\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mFalse\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 540\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31mKeyboardInterrupt\u001b[0m: " + ] + } + ], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 90, + "id": "89732855-0923-4345-a027-064eb471e4bc", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
algn_samplesloss
0sfci50.00
1sfci50.00
2sfci50.00
3sfci50.00
4sfci50.00
............
95ifci5000.01
96ifci5000.01
97ifci5000.01
98ifci5000.01
99ifci5000.01
\n", + "

100 rows × 3 columns

\n", + "
" + ], + "text/plain": [ + " alg n_samples loss\n", + "0 sfci 50.0 0\n", + "1 sfci 50.0 0\n", + "2 sfci 50.0 0\n", + "3 sfci 50.0 0\n", + "4 sfci 50.0 0\n", + ".. ... ... ...\n", + "95 ifci 5000.0 1\n", + "96 ifci 5000.0 1\n", + "97 ifci 5000.0 1\n", + "98 ifci 5000.0 1\n", + "99 ifci 5000.0 1\n", + "\n", + "[100 rows x 3 columns]" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "res_df = pd.DataFrame(sfci_results)\n", + "res_df[\"alg\"] = \"sfci\"\n", + "temp_df = pd.DataFrame(ifci_results)\n", + "temp_df[\"alg\"] = \"ifci\"\n", + "res_df = pd.concat((res_df, temp_df), axis=0)\n", + "res_df = res_df.reset_index()\n", + "\n", + "res_df = pd.melt(\n", + " res_df,\n", + " id_vars=\"alg\",\n", + " var_name=\"n_samples\",\n", + " value_vars=n_sample_grid,\n", + " value_name=\"loss\",\n", + ")\n", + "\n", + "display(res_df)" + ] + }, + { + "cell_type": "code", + "execution_count": 94, + "id": "dbacc3de-4a21-4b98-b10c-6c2a8cce6f68", + "metadata": {}, + "outputs": [], + "source": [ + "res_df.columns = [\"Alg.\", \"# Samples\", \"Loss\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 96, + "id": "ce1d3be3-bbfa-4c6e-b040-7a30f3152fa1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 96, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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NbxVAABUv9WrV2vfvn3q27ev6tevb20fMmSIPD09tWjRIuXn55e7jh9//FGSdPPNN9v1RUVF2YSla4HTBSOpeAL00qVL5evrq2eeeUbTpk1TmzZtlJiYqHbt2l1yeTc3N3322WeaMmWKfvzxR40dO1aLFi3SoEGDtGnTJrVq1aoGtgIAgCvrnXfekSTrabQSISEhGjBggE6cOKHly5eXu46MjAxJUt26dUvtL6v9auV0k69LxMXFKS4urtwxsbGxZX5wnLe3tyZPnqzJkydfifIAAHCokydP6tNPP5UkPfDAA3rggQdKHffOO++U+35a8ikTx48f1w033GDXf/z48aoX60KcNhgBAICyLV68WBcuXFCnTp3Uvn37Usd89tln+u9//6sDBw6oUaNGpY7p0KGDPvnkE61fv169evWy6UtJSbnkx3BdbQhGAAC4oJKJ1W+++ab1auzfeuGFF/Tyyy9rwYIFmj59eqljhgwZopdeeklz5szRiBEjrHOKDMPQpEmTVFhYWOpyu3btkiRdf/31Vd0Up+KUc4wAAEDZEhIStHv3brVp06bMUCRJjz76qCwWi9577z0VFBSUOqZJkyZ66aWXdOrUKbVr106PP/64/vSnP6lz585KSkpSu3btSr3BY8uWLe3uN3g1IBgBAOBiSo4WjRw5stxx0dHR6tOnj9LS0vT555+XOW7SpEl6//33FRUVpffee08LFy5Uy5YtlZiYqIKCAus8pGuBxShr9jJsbNmyRZ06ddLmzZvVsWNHR5cDAHCwwsJC7d69W82bN79q7/WTmZmpunXrqn379qXeeNnRLud7UNH3b44YAQBwjTt58qTd/Y4KCgr09NNPKzc3V3fffbeDKqt5TL4GAOAa9/HHH+vFF19Unz591KBBA6Wnp2vdunXavXu32rdvrzFjxji6xBpDMAIA4Bp344036uabb9a6det0+vRpSVKjRo303HPP6dlnn5WPj4+DK6w5VQpGZ86cUVpampo0aSIvLy9r+3vvvadPP/1Ufn5+GjduXLkz5gEAgGN16NBBy5Ytc3QZTqFKwejPf/6zlixZohMnTljb5syZo3HjxlnvSP3pp5/qhx9+4GM4AACA06vS5OvExET17t3b5hDb66+/rvr162vdunX6z3/+I0n661//WrUqAQAAakCVjhgdOXJEvXv3tn79yy+/6PDhw5ox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CgoKbManpKQYdevWNXx9fY0///nPRnx8vHHnnXcakowXXnjB7jm//PJLw93d3YiKijJmzZplzJkzx3rn65UrV17R7a0J48aNM7p06WI8++yzxrx584w33njDiIuLMywWixEeHm5zw0vDcK3XNcGoBi1dutTo2rWr4ePjYwQGBhp33nmnsW3bNkeX5TRKbrhW1uO3Lly4YLzyyitGs2bNDE9PT6NevXrGY489Zpw8ebLU9Z84ccJ47LHHjHr16hmenp5Gs2bNjFdffdXIz88vdfzWrVuNO+64wwgMDDR8fHyMrl272v216KpKbrhW1iMqKspmPPu68latWmXExcUZ0dHRhq+vr+Hp6WlERUUZQ4YMMTZu3Gg3nn1d/UoLRoZhGOfOnTMmTpxoNGzY0PDw8DAaNmxoPPPMM3YfUVFi//79xv3332+EhoYaXl5eRps2bYz58+eX+byrV682YmNjDT8/P+vZgdJucuiKli9fbvTr18+IjIw0vL29DS8vL6NFixbG+PHjjePHj9uNd6XXtcUwDKNiJ98AAACuTky+BgAAMBGMAAAATAQjAAAAE8EIAADARDACAAAwEYwAAABMBCMAAAATwQgAAMBEMAIAADARjADAAaZMmSKLxaKEhARHlwLgIgQjANUiJydH3t7eGj9+vLXtscceU0BAgAoKCiq8vqVLl6pfv3667rrr5OHhodDQULVq1UpDhw7V4sWLq7N0ALCq5egCAFwdEhMTlZeXp169elnbvv32W91yyy2qVativ2oee+wxxcfHy8fHR3fccYcaNWokwzC0a9cuff7550pISNCwYcOqexMAgGAEoHqsXr1a7u7uuuWWWyRJBw8e1P79+/XHP/6xQutZv3694uPjFRkZqaSkJEVGRtr05+fnc/oJwBXDqTQAlXLu3Dnt3bvX+vjmm2/UsmVLnThxQnv37tV//vMfSVKjRo2sY3Jyci653u+//16SNHjwYLtQJEkeHh7q27evTduFCxc0d+5c9e/fX1FRUfLy8lJISIj69OmjFStWlPo80dHRio6OVlZWlp566ik1aNBAPj4+at++vT799FNJUkFBgaZPn65mzZrJ29tbTZo00dy5c+3WlZCQIIvFoilTpigpKUl9+vRRYGCg/P39dfvtt+uHH3645HZfbNeuXRo+fLgaNGggT09P1a1bV0OGDNGvv/5qN/b48eOaMGGCWrRoIT8/PwUFBalFixYaPny49u/fX6HnBcARIwCV9PHHH2vEiBF27c2aNbP5+ve//731/2vWrFFsbGy56w0NDZUk7dmz57JrSU9P19ixYxUTE6O+ffuqTp06SktL0+eff67+/fsrPj5eI0eOtFsuPz9fffv2VXp6uu666y5duHBB//rXvzR48GB98803evPNN7Vx40b97ne/k5eXl5YuXaoxY8aoTp06uu++++zWt3HjRr366qvq06eP/vjHP2rv3r1atmyZ1q1bp2+++UY9evS45LZ8/fXX+v3vf6/8/HwNGDBATZs2VWpqqpYtW6Yvv/xSa9asUceOHSVJ2dnZ6t69u/bt26e+fftqwIABMgxDKSkpWr58ueLi4tS4cePL3o8AJBkAUAkHDx40li5daixdutR46qmnDEnGSy+9ZG3z9fU1evbsaf166dKlxokTJy653tTUVCMwMNCQZAwYMMD4xz/+YezevdsoKioqc5nc3Fzj8OHDdu1nz541brjhBiM4ONjIzs626YuKijIkGXfeeaeRm5trbV+3bp0hyQgODjY6d+5snDlzxtq3b98+w8PDw2jfvr3NutasWWNIMiQZc+bMsen79NNPDUlG06ZNjcLCQmv75MmTDUnGmjVrrG3p6elGUFCQERoaauzYscNmPdu3bzf8/PyMDh06WNs+++wzQ5Ixbtw4u23Py8szMjMzS9lbAMpDMAJQZePHjzc8PDyMrKwswzAM49dffzUkGW+++Wal1rd69WqjSZMm1rAhyfD39zduv/1244MPPjAKCgoue12zZs0yJBlr1661aS8JRnv37rVbplGjRoYk49tvv7Xri42NNWrVqmVTQ0kw+m34KXHrrbcakoyEhARrW2nBaPbs2YYkY+7cuaVuy7hx4wxJ1tBUEowmTZpU/k4AcNk4lQagylavXq0uXbrIz89PkrR27VpJ0q233lqp9fXs2VO7d+9WYmKi1q5dqx9//FGJiYlauXKlVq5cqcWLF+uLL76Ql5eXdZkdO3Zo5syZWrdundLS0pSbm2uzziNHjtg9T1BQkJo0aWLXHhERoQMHDqhTp052ffXr11dBQYGOHTum+vXr2/T16NFDbm72UzdjY2Ot21HePklKSpIkbdu2TVOmTLHr3717tyRp586datWqlW699VbVr19fr732mrZs2aL+/fure/fuat++vdzd3ct8HgBlIxgBqLCEhATrlWFFRUXatm2bOnfubH0z/+qrr+Tu7m6dgC2p1Df68ri5ualHjx7WeTmGYWjVqlUaNmyY/vvf/+qtt97SuHHjJEkbNmxQr169VFBQoN69e2vgwIEKCAiQm5ubtm7dquXLlysvL8/uOQIDA0t97pLbC5TWX9KXn59v11e3bt1S11evXj1JUkZGRrnbfPr0aUlSfHx8ueOysrIkSQEBAdqwYYMmT56szz77TCtXrpQkhYWF6YknntDzzz8vDw+PctcFwBbBCECFJSQkaOrUqTZtycnJSk5Otmm7eExFg9FvWSwW3XbbbXr55Zc1cuRIrV692hqMXn75ZeXk5JQ6ufvVV1/V8uXLq/Tcl+v48eOlth87dkxS2UGsREn/tm3b1LZt28t6zsjISC1cuFCGYeiXX37R6tWrNW/ePL300ksqKirStGnTKrAFALhcH0CFTZkyRUbxHEU9/fTT8vLyUk5OjgzD0M6dOyVJb731lnWMYRjV9tz+/v6SZLPOvXv3KiQkpNQr3kpO69WE9evXq6ioyK695Ohahw4dyl2+W7dukqTvvvuuws9tsVh0ww03aMyYMVq1apUkWW87AODyEYwAVMmaNWvUrVs3eXt7S/pfCLjUZfll+frrr7Vs2bJST1VlZWVp9uzZkmS9kaRUfE+i9PR0/fTTTzbjFy5caD29VBP27NmjN99806Zt+fLlWrt2rZo2bXrJy/VHjBihoKAgTZ06VZs2bbLrLyoqsrm55Y4dO0o9SlXS5uvrW4mtAK5tnEoDUGlnz57V1q1b9cILL1jbEhISVK9ePV1//fWVWueuXbv01FNPKTg4WD169FCzZs1Uq1Ytpaam6ssvv9TZs2d14403avTo0dZlxo0bp5UrV+rmm2/Wvffeq8DAQP3www9av3694uLi9NFHH1V5Wy9Hv3799PTTT2vFihVq166d9T5G3t7eevfdd0udmH2x0NBQffTRR7r77rvVrVs39e7dWzfccIMsFosOHz6spKQknT592jqxfNWqVZo4caJuuukmNW/eXNddd51SU1O1fPlyubm5aeLEiTWx2cBVhWAEoNLWrl2roqIim6NDa9eurfTVaJI0dOhQBQQEaNWqVdq2bZvWrVunrKwsBQUFqX379rrnnns0cuRIeXp6Wpfp16+fPv/8c7388sv697//LXd3d3Xt2lVr1qzR/v37aywY3XjjjXrxxRf1wgsvaO7cuTIMQ7169dL06dPVpUuXy1pH79699dNPP+n111/XypUr9d1338nT01MRERHq1auXBg8ebB17++2369ChQ1q3bp2WL1+uzMxMhYeHq2/fvho/frxiYmKu1KYCVy2LUZ0n/wHgGpSQkKCePXtq8uTJVZ5kDsCxmGMEAABgIhgBAACYCEYAAAAm5hgBAACYOGIEAABgIhgBAACYCEYAAAAmghEAAICJYAQAAGAiGAEAAJgIRgAAACaCEQAAgOn/AUtGQBwe7/buAAAAAElFTkSuQmCC\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "sns.set_context(\"paper\", font_scale=1.5)\n", + "sns.scatterplot(data=res_df, x=\"# Samples\", y=\"Loss\", hue=\"Alg.\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "59c43562-50fd-47a2-a037-425e1e5310db", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pywhy-discover", + "language": "python", + "name": "pywhy-discover" + }, + "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.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/tutorials/multi-domain/example-sfci-algo.ipynb b/doc/tutorials/multi-domain/example-sfci-algo.ipynb new file mode 100644 index 000000000..78781577e --- /dev/null +++ b/doc/tutorials/multi-domain/example-sfci-algo.ipynb @@ -0,0 +1,2371 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Causal discovery from observational and/or interventional data across multiple domains\n", + "\n", + "Causal discovery algorithms such as the PC, FCI and GeS algorithm typically assume data is iid and come from a single distribution (i.e. a single domain). In the real world, causal structure is typically shared among similar domains, but the distributional functions may differ (e.g. one domain may have Gaussian noise, whereas another domain has Poisson noise).\n", + "\n", + "The S-FCI algorithm is introduced as a constraint-based discovery method that is a generalization of the FCI algorithm and moreover a generalization of the I-FCI/$\\Psi$-FCI algorithms. It correctly leverages data across distributions to learn a S-PAG, which is a Markov equivalence class of augmented selection diagrams. Augmented selection diagrams are selection diagrams with additional F-nodes indicating interventional distributions in any specified domain. \n", + "\n", + "Here, we demonstrate how the S-FCI algorithm typically learns more compared to its predecessors on simulated data stemming from a real experiment. \n", + "\n", + "This is done because one of the challenges of evaluating modern causal discovery is the lack of a suite of datasets that have multiple domains, and various types of interventions, and an accepted ground-truth graph.\n", + "\n", + "## Pseudo-Real Data: Protein Sequencing Experiment\n", + "\n", + "The famous Sachs dataset [2]_ is a wet-lab experiment dataset where protein expression level were observed in resting-state and then proteins were perturbed in various scenarios to obtain interventional data. This dataset can be viewed as observational and interventional data stemming from a single domain.\n", + "\n", + "The ground truth graph that we will assume is true is: https://www.bnlearn.com/research/sachs05/\n", + "\n", + "We will create an in-silico multi-domain dataset from the real data. We will do this by i) specifying at random nodes that are \"latent\", causing latent confounders and ii) choosing random nodes with S-nodes pointing to them causing shifts in distribution from source to target domain.\n", + "\n", + "### Setup\n", + "\n", + "The setup we will first consider the ground-truth graph" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + "%load_ext lab_black" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "from IPython.display import display_svg" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "from pprint import pprint\n", + "import numpy as np\n", + "import scipy\n", + "import pandas as pd\n", + "import collections\n", + "from itertools import combinations\n", + "import bnlearn\n", + "import pooch\n", + "from cdt.data import load_dataset\n", + "\n", + "from pywhy_graphs.functional import (\n", + " make_graph_linear_gaussian,\n", + " make_graph_multidomain,\n", + " set_node_attributes_with_G,\n", + " apply_linear_soft_intervention,\n", + " sample_multidomain_lin_functions,\n", + ")\n", + "from pywhy_graphs.classes import AugmentedGraph\n", + "from pywhy_graphs.viz import draw\n", + "\n", + "from dodiscover.cd import KernelCDTest\n", + "from dodiscover.ci import KernelCITest, FisherZCITest, Oracle, GSquareCITest\n", + "from dodiscover.constraint.skeleton import LearnMultiDomainSkeleton\n", + "from dodiscover.datasets import sample_from_graph\n", + "\n", + "from dodiscover import PsiFCI, SFCI, Context, make_context, InterventionalContextBuilder" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "seed = 1234\n", + "rng = np.random.default_rng(seed)\n", + "n_jobs = -1" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "alpha = 0.05" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Load the data" + ] + }, + { + "cell_type": "code", + "execution_count": 119, + "metadata": {}, + "outputs": [], + "source": [ + "# use pooch to download robustly from a url\n", + "url = \"https://www.bnlearn.com/book-crc/code/sachs.interventional.txt.gz\"\n", + "file_path = pooch.retrieve(\n", + " url=url,\n", + " known_hash=\"md5:39ee257f7eeb94cb60e6177cf80c9544\",\n", + ")\n", + "\n", + "df = pd.read_csv(file_path, delimiter=\" \")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "# download purely observational data\n", + "# data = bnlearn.import_example(data=\"sachs\", n=10000, verbose=3)" + ] + }, + { + "cell_type": "code", + "execution_count": 122, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Raf Mek Plcg PIP2 PIP3 Erk Akt PKA PKC P38 Jnk INT\n", + "0 1 1 1 2 3 2 1 3 1 2 1 8\n", + "1 1 1 1 1 3 3 2 3 1 2 1 8\n", + "2 1 1 2 2 3 2 1 3 2 1 1 8\n", + "3 1 1 1 1 3 2 1 3 1 3 1 8\n", + "4 1 1 1 1 3 2 1 3 1 1 1 8\n", + "(5400, 12)\n" + ] + } + ], + "source": [ + "print(df.head())\n", + "print(df.shape)" + ] + }, + { + "cell_type": "code", + "execution_count": 121, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Raf [1, 3, 2]\n", + "Mek [1, 2, 3]\n", + "Plcg [1, 2, 3]\n", + "PIP2 [2, 1, 3]\n", + "PIP3 [3, 2, 1]\n", + "Erk [2, 3, 1]\n", + "Akt [1, 2, 3]\n", + "PKA [3, 2, 1]\n", + "PKC [1, 2, 3]\n", + "P38 [2, 1, 3]\n", + "Jnk [1, 2, 3]\n", + "INT [8, 0, 7, 9, 4, 2]\n", + "dtype: object" + ] + }, + "execution_count": 121, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.apply(lambda x: x.unique())" + ] + }, + { + "cell_type": "code", + "execution_count": 123, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['PKC', 'Raf', 'PKA', 'P38', 'PIP3', 'Plcg']\n" + ] + } + ], + "source": [ + "perturbations = [df.columns[perturbed_col] for perturbed_col in df[\"INT\"].unique()]\n", + "n_proteins = len(df.columns) - 1\n", + "\n", + "print(perturbations)" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "# the ground-truth dag is shown here: XXX: comment in when errors are fixed\n", + "ground_truth_dag = bnlearn.import_DAG(\"sachs\", verbose=False)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Erk\n", + "\n", + "Erk\n", + "\n", + "\n", + "\n", + "Akt\n", + "\n", + "Akt\n", + "\n", + "\n", + "\n", + "Erk->Akt\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKA\n", + "\n", + "PKA\n", + "\n", + "\n", + "\n", + "PKA->Erk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Mek\n", + "\n", + "Mek\n", + "\n", + "\n", + "\n", + "PKA->Mek\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKA->Akt\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Raf\n", + "\n", + "Raf\n", + "\n", + "\n", + "\n", + "PKA->Raf\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Jnk\n", + "\n", + "Jnk\n", + "\n", + "\n", + "\n", + "PKA->Jnk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "P38\n", + "\n", + "P38\n", + "\n", + "\n", + "\n", + "PKA->P38\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Mek->Erk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC\n", + "\n", + "PKC\n", + "\n", + "\n", + "\n", + "PKC->PKA\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC->Mek\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC->Raf\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC->Jnk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC->P38\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Raf->Mek\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PIP3\n", + "\n", + "PIP3\n", + "\n", + "\n", + "\n", + "PIP2\n", + "\n", + "PIP2\n", + "\n", + "\n", + "\n", + "PIP3->PIP2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Plcg\n", + "\n", + "Plcg\n", + "\n", + "\n", + "\n", + "Plcg->PIP3\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Plcg->PIP2\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ground_truth_G = ground_truth_dag[\"model\"].to_directed()\n", + "G = draw(ground_truth_G, direction=\"TD\", shape=\"circle\")\n", + "G\n", + "# G.render(\n", + "# outfile=\"/Users/adam2392/Dropbox/Apps/Overleaf/Learning selection diagrams (observational)/Figures/Appendix/ground_truth_sachs_bnlearn.pdf\",\n", + "# format=\"pdf\",\n", + "# cleanup=True,\n", + "# )\n", + "# ['PKC', 'Raf', 'PKA', 'P38', 'PIP3', 'Plcg']" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Generate Artificial Multi-Domain Discrete Interventional Dataset" + ] + }, + { + "cell_type": "code", + "execution_count": 124, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[0.18575566 0.13968558 0.67455876]\n", + "1.0\n" + ] + } + ], + "source": [ + "rng = np.random.default_rng(seed)\n", + "\n", + "# generate now bernoulli probability exogenous per protein\n", + "prior_protein_exp = rng.dirichlet(\n", + " rng.standard_gamma(rng.integers(1, 4), size=3), 1\n", + ").squeeze()\n", + "\n", + "outcome_values = np.array([1, 2, 3])\n", + "nodes_to_resample = np.array([\"Erk\", \"Mek\", \"PIP2\"])\n", + "\n", + "print(prior_protein_exp)\n", + "print(prior_protein_exp.sum(axis=0))" + ] + }, + { + "cell_type": "code", + "execution_count": 144, + "metadata": {}, + "outputs": [], + "source": [ + "def resample_dataset(\n", + " G,\n", + " df,\n", + " prior_multi_dist,\n", + " nodes_to_resample,\n", + " outcome_values,\n", + " n_samples=1000,\n", + " seed=12345,\n", + "):\n", + " rng = np.random.default_rng(seed)\n", + "\n", + " new_df = np.zeros((n_samples, len(df.columns)))\n", + " for idx in range(n_samples):\n", + " row_idx = rng.integers(0, len(df))\n", + "\n", + " new_df[idx, :] = df.iloc[row_idx, :]\n", + "\n", + " for jdx, node in enumerate(nodes_to_resample):\n", + " prior_dist = prior_multi_dist\n", + " col_idx = np.argwhere(df.columns == node).squeeze()\n", + "\n", + " # sample which index from 1, 2, or 3 it hit\n", + " new_sample_idx = rng.multinomial(1, pvals=prior_dist, size=1).squeeze()\n", + " new_sample = outcome_values[np.argwhere(new_sample_idx == 1).squeeze()]\n", + " new_df[idx, col_idx] = new_sample\n", + "\n", + " # print(\"new sample for \", node, new_sample)\n", + " # sample the children according to a re-weighted Dirichlet distribution\n", + " children = list(G.successors(node))\n", + " for child in children:\n", + " child_prior = prior_multi_dist.copy()\n", + " child_prior[new_sample_idx] *= new_sample\n", + " child_prior = rng.dirichlet(child_prior, 1)\n", + "\n", + " child_idx = np.argwhere(df.columns == child).squeeze()\n", + "\n", + " # sample which index from 1, 2, or 3 it hit for children\n", + " child_sample_idx = rng.multinomial(\n", + " 1, pvals=child_prior, size=1\n", + " ).squeeze()\n", + " child_sample = outcome_values[\n", + " np.argwhere(child_sample_idx == 1).squeeze()\n", + " ]\n", + " new_df[idx, child_idx] = child_sample\n", + " # print(\"New sample for \", child, child_sample)\n", + "\n", + " new_df = pd.DataFrame(new_df)\n", + " new_df.columns = df.columns\n", + " return new_df" + ] + }, + { + "cell_type": "code", + "execution_count": 145, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[1 2 3]\n", + "['Erk' 'Mek' 'PIP2']\n" + ] + } + ], + "source": [ + "print(outcome_values)\n", + "print(nodes_to_resample)" + ] + }, + { + "cell_type": "code", + "execution_count": 146, + "metadata": {}, + "outputs": [], + "source": [ + "new_df = resample_dataset(\n", + " ground_truth_G,\n", + " df,\n", + " prior_multi_dist=prior_protein_exp,\n", + " nodes_to_resample=nodes_to_resample,\n", + " outcome_values=outcome_values,\n", + " n_samples=5000,\n", + " seed=12345,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 147, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(5000, 12)\n", + "Raf [1.0, 2.0, 3.0]\n", + "Mek [1.0, 3.0, 2.0]\n", + "Plcg [1.0, 3.0, 2.0]\n", + "PIP2 [3.0, 2.0, 1.0]\n", + "PIP3 [1.0, 3.0, 2.0]\n", + "Erk [3.0, 1.0, 2.0]\n", + "Akt [3.0, 1.0, 2.0]\n", + "PKA [2.0, 1.0, 3.0]\n", + "PKC [2.0, 1.0, 3.0]\n", + "P38 [1.0, 2.0, 3.0]\n", + "Jnk [2.0, 1.0, 3.0]\n", + "INT [0.0, 7.0, 4.0, 9.0, 8.0, 2.0]\n", + "dtype: object\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
RafMekPlcgPIP2PIP3ErkAktPKAPKCP38JnkINT
01.01.01.03.01.03.03.02.02.01.02.00.0
11.01.01.02.03.01.03.02.02.01.01.07.0
22.03.01.03.01.01.01.02.02.02.02.04.0
33.03.01.03.03.01.03.02.01.01.01.07.0
41.03.01.02.02.01.02.02.02.01.02.00.0
\n", + "
" + ], + "text/plain": [ + " Raf Mek Plcg PIP2 PIP3 Erk Akt PKA PKC P38 Jnk INT\n", + "0 1.0 1.0 1.0 3.0 1.0 3.0 3.0 2.0 2.0 1.0 2.0 0.0\n", + "1 1.0 1.0 1.0 2.0 3.0 1.0 3.0 2.0 2.0 1.0 1.0 7.0\n", + "2 2.0 3.0 1.0 3.0 1.0 1.0 1.0 2.0 2.0 2.0 2.0 4.0\n", + "3 3.0 3.0 1.0 3.0 3.0 1.0 3.0 2.0 1.0 1.0 1.0 7.0\n", + "4 1.0 3.0 1.0 2.0 2.0 1.0 2.0 2.0 2.0 1.0 2.0 0.0" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "print(new_df.shape)\n", + "print(new_df.apply(lambda x: x.unique()))\n", + "display(new_df.head())" + ] + }, + { + "cell_type": "code", + "execution_count": 150, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "12 12 12\n" + ] + } + ], + "source": [ + "# %%\n", + "# Preprocess the dataset\n", + "# ----------------------\n", + "# Since the data is one dataframe, we need to process it into a form\n", + "# that is acceptable by dodiscover's :class:`constraint.PsiFCI` algorithm. We\n", + "# will form a list of separate dataframes.\n", + "unique_ints = df[\"INT\"].unique()\n", + "\n", + "# get the list of intervention targets and list of dataframe associated with each intervention\n", + "intervention_targets = []\n", + "data_cols = [col for col in df.columns if col != \"INT\"]\n", + "data = []\n", + "domain_ids = []\n", + "for interv_idx in unique_ints:\n", + " _data = df[df[\"INT\"] == interv_idx][data_cols]\n", + " data.append(_data)\n", + " intervention_targets.append(df.columns[interv_idx])\n", + " domain_ids.append(1)\n", + "\n", + " # append second domain\n", + " _data = new_df[new_df[\"INT\"] == interv_idx][data_cols]\n", + " data.append(_data)\n", + " intervention_targets.append(df.columns[interv_idx])\n", + " domain_ids.append(2)\n", + "\n", + "print(len(data), len(intervention_targets), len(domain_ids))" + ] + }, + { + "cell_type": "code", + "execution_count": 149, + "metadata": {}, + "outputs": [ + { + "ename": "Exception", + "evalue": "only integers, slices (`:`), ellipsis (`...`), numpy.newaxis (`None`) and integer or boolean arrays are valid indices", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31m_RemoteTraceback\u001b[0m Traceback (most recent call last)", + "\u001b[0;31m_RemoteTraceback\u001b[0m: \n\"\"\"\nTraceback (most recent call last):\n File \"/Users/adam2392/Documents/dodiscover/dodiscover/constraint/skeleton.py\", line 112, in _test_xy_edges\n test_stat, pvalue = parallel_fun(\n File \"/Users/adam2392/Documents/dodiscover/dodiscover/constraint/skeleton.py\", line 692, in evaluate_edge\n test_stat, pvalue = conditional_test_func.test(data, set({X}), set({Y}), Z, **kwargs)\n File \"/Users/adam2392/Documents/dodiscover/dodiscover/ci/g_test.py\", line 459, in test\n stat, pvalue = g_square_discrete(df, x_var, y_var, z_covariates, levels=self.levels)\n File \"/Users/adam2392/Documents/dodiscover/dodiscover/ci/g_test.py\", line 362, in g_square_discrete\n contingency_tble = _calculate_contingency_tble(\n File \"/Users/adam2392/Documents/dodiscover/dodiscover/ci/g_test.py\", line 80, in _calculate_contingency_tble\n contingency_tble[idx, jdx, kdx] += 1\nIndexError: only integers, slices (`:`), ellipsis (`...`), numpy.newaxis (`None`) and integer or boolean arrays are valid indices\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n File \"/Users/adam2392/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/externals/loky/process_executor.py\", line 428, in _process_worker\n r = call_item()\n File \"/Users/adam2392/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/externals/loky/process_executor.py\", line 275, in __call__\n return self.fn(*self.args, **self.kwargs)\n File \"/Users/adam2392/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/_parallel_backends.py\", line 620, in __call__\n return self.func(*args, **kwargs)\n File \"/Users/adam2392/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/parallel.py\", line 288, in __call__\n return [func(*args, **kwargs)\n File \"/Users/adam2392/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/parallel.py\", line 288, in \n return [func(*args, **kwargs)\n File \"/Users/adam2392/Documents/dodiscover/dodiscover/constraint/skeleton.py\", line 125, in _test_xy_edges\n raise Exception(e)\nException: only integers, slices (`:`), ellipsis (`...`), numpy.newaxis (`None`) and integer or boolean arrays are valid indices\n\"\"\"", + "\nThe above exception was the direct cause of the following exception:\n", + "\u001b[0;31mException\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m/var/folders/6_/sl83qtkd68x3_mvfys07_6qm0000gn/T/ipykernel_71125/2180799116.py\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 33\u001b[0m \u001b[0;31m# run the algorithm using the :meth:`constraint.PsiFCI.fit` API, which is similar to scikit-learn's\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 34\u001b[0m \u001b[0;31m# `fit` design. All fitted attributes contain an underscore at the end.\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 35\u001b[0;31m learner = learner.fit(\n\u001b[0m\u001b[1;32m 36\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mctx\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdomain_indices\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mdomain_ids\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mintervention_targets\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mintervention_targets\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 37\u001b[0m )\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/sfcialg.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, data, context, domain_indices, intervention_targets)\u001b[0m\n\u001b[1;32m 113\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mintervention_targets\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mintervention_targets\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 114\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 115\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 116\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 117\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m_apply_rule11\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mgraph\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mEquivalenceClass\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mContext\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m->\u001b[0m \u001b[0mTuple\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mbool\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mList\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/intervention.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, data, context)\u001b[0m\n\u001b[1;32m 187\u001b[0m )\n\u001b[1;32m 188\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 189\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 190\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 191\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m_apply_rule11\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mgraph\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mEquivalenceClass\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mContext\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m->\u001b[0m \u001b[0mTuple\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mbool\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mList\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/_classes.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, data, context)\u001b[0m\n\u001b[1;32m 215\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 216\u001b[0m \u001b[0;31m# learn skeleton graph and the separating sets per variable\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 217\u001b[0;31m graph, self.separating_sets_ = self.learn_skeleton(\n\u001b[0m\u001b[1;32m 218\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcontext_\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mseparating_sets_\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 219\u001b[0m )\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/sfcialg.py\u001b[0m in \u001b[0;36mlearn_skeleton\u001b[0;34m(self, data, context, sep_set)\u001b[0m\n\u001b[1;32m 68\u001b[0m \u001b[0mn_jobs\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mn_jobs\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 69\u001b[0m )\n\u001b[0;32m---> 70\u001b[0;31m self.skeleton_learner_.fit(\n\u001b[0m\u001b[1;32m 71\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdomain_indices\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mintervention_targets\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdebug\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdebug\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 72\u001b[0m )\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/skeleton.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, data, context, domain_indices, intervention_targets, check_input, debug)\u001b[0m\n\u001b[1;32m 1784\u001b[0m \u001b[0;34mf\"{this_f_nodes} grouped with S-node: {s_node}\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1785\u001b[0m )\n\u001b[0;32m-> 1786\u001b[0;31m self._learn_skeleton(\n\u001b[0m\u001b[1;32m 1787\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1788\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mcontext\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/skeleton.py\u001b[0m in \u001b[0;36m_learn_skeleton\u001b[0;34m(self, data, context, condsel_method, conditional_test_func, possible_x_nodes, skipped_y_nodes, skipped_z_nodes, cross_distribution_test, s_node)\u001b[0m\n\u001b[1;32m 448\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 449\u001b[0m \u001b[0;31m# run parallelized loop\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 450\u001b[0;31m out = Parallel(n_jobs=self.n_jobs)(\n\u001b[0m\u001b[1;32m 451\u001b[0m delayed(_test_xy_edges)(\n\u001b[1;32m 452\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mevaluate_edge\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/parallel.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(self, iterable)\u001b[0m\n\u001b[1;32m 1096\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1097\u001b[0m \u001b[0;32mwith\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_backend\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mretrieval_context\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1098\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mretrieve\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1099\u001b[0m \u001b[0;31m# Make sure that we get a last message telling us we are done\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1100\u001b[0m \u001b[0melapsed_time\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mtime\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mtime\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m-\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_start_time\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/parallel.py\u001b[0m in \u001b[0;36mretrieve\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 973\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 974\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mgetattr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_backend\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'supports_timeout'\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;32mFalse\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 975\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_output\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mextend\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mjob\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mget\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtimeout\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mtimeout\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 976\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 977\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_output\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mextend\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mjob\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mget\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/site-packages/joblib/_parallel_backends.py\u001b[0m in \u001b[0;36mwrap_future_result\u001b[0;34m(future, timeout)\u001b[0m\n\u001b[1;32m 565\u001b[0m AsyncResults.get from multiprocessing.\"\"\"\n\u001b[1;32m 566\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 567\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mfuture\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mresult\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtimeout\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mtimeout\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 568\u001b[0m \u001b[0;32mexcept\u001b[0m \u001b[0mCfTimeoutError\u001b[0m \u001b[0;32mas\u001b[0m \u001b[0me\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 569\u001b[0m \u001b[0;32mraise\u001b[0m \u001b[0mTimeoutError\u001b[0m \u001b[0;32mfrom\u001b[0m \u001b[0me\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/concurrent/futures/_base.py\u001b[0m in \u001b[0;36mresult\u001b[0;34m(self, timeout)\u001b[0m\n\u001b[1;32m 437\u001b[0m \u001b[0;32mraise\u001b[0m \u001b[0mCancelledError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 438\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_state\u001b[0m \u001b[0;34m==\u001b[0m \u001b[0mFINISHED\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 439\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m__get_result\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 440\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 441\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_condition\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mwait\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtimeout\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/pywhy-discover/lib/python3.9/concurrent/futures/_base.py\u001b[0m in \u001b[0;36m__get_result\u001b[0;34m(self)\u001b[0m\n\u001b[1;32m 389\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_exception\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 390\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 391\u001b[0;31m \u001b[0;32mraise\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_exception\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 392\u001b[0m \u001b[0;32mfinally\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 393\u001b[0m \u001b[0;31m# Break a reference cycle with the exception in self._exception\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31mException\u001b[0m: only integers, slices (`:`), ellipsis (`...`), numpy.newaxis (`None`) and integer or boolean arrays are valid indices" + ] + } + ], + "source": [ + "# Setup constraint-based learner\n", + "# ------------------------------\n", + "# Since we have access to interventional data, the causal discovery algorithm\n", + "# we will use that leverages CI and CD tests to estimate causal constraints\n", + "# is the Psi-FCI algorithm :footcite:`Jaber2020causal`.\n", + "\n", + "# Our dataset is comprised of discrete valued data, so we will utilize the\n", + "# G^2 (Chi-square) CI test.\n", + "ci_estimator = GSquareCITest(data_type=\"discrete\")\n", + "\n", + "# Since our data is entirely discrete, we can also use the G^2 test as our\n", + "# CD test.\n", + "cd_estimator = GSquareCITest(data_type=\"discrete\")\n", + "\n", + "alpha = 0.05\n", + "learner = SFCI(\n", + " ci_estimator=ci_estimator,\n", + " cd_estimator=cd_estimator,\n", + " alpha=alpha,\n", + " max_combinations=10,\n", + " max_cond_set_size=4,\n", + " n_jobs=-1,\n", + ")\n", + "\n", + "# create context with information about the interventions\n", + "ctx_builder = make_context(create_using=InterventionalContextBuilder)\n", + "ctx: Context = ctx_builder.variables(data=data[0]).num_distributions(len(data)).build()\n", + "\n", + "# %%\n", + "# Run the learning process\n", + "# ------------------------\n", + "# We have setup our causal context and causal discovery learner, so we will now\n", + "# run the algorithm using the :meth:`constraint.PsiFCI.fit` API, which is similar to scikit-learn's\n", + "# `fit` design. All fitted attributes contain an underscore at the end.\n", + "learner = learner.fit(\n", + " data, ctx, domain_indices=domain_ids, intervention_targets=intervention_targets\n", + ")\n", + "\n", + "# %%\n", + "# Analyze the results\n", + "# ===================\n", + "# Now that we have learned the graph, we will show it here. Note differences and similarities\n", + "# to the ground-truth DAG that is \"assumed\". Moreover, note that this reproduces Supplementary\n", + "# Figure 8 in :footcite:`Jaber2020causal`.\n", + "est_pag = learner.graph_\n", + "\n", + "print(f\"There are {len(est_pag.to_undirected().edges)} edges in the resulting PAG\")\n", + "\n", + "# %%\n", + "# Visualize the full graph including the F-node\n", + "# dot_graph = draw(est_pag, direction=\"LR\")\n", + "# dot_graph.render(outfile=\"psi_pag_full.png\", view=True, cleanup=True)\n", + "\n", + "# %%\n", + "# Visualize the graph without the F-nodes\n", + "est_pag_no_fnodes = est_pag.subgraph(ctx.get_non_augmented_nodes())\n", + "dot_graph = draw(est_pag_no_fnodes, direction=\"LR\")\n", + "dot_graph.render(outfile=\"psi_pag.png\", view=True, cleanup=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "# Linear SCM Simulation: Generate Ground-Truth Data\n", + "\n", + "First, we assume the causal diagram is induced by a linear SCM. In this setting, we are able to test the performance of S-FCI vs other algorithms when we artificially introduce differnet domain settings that simulate the collection of observations and experiments across e.g. different labs, and hospitals." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Augmented Sachs Graph\n", + "\n", + "\n", + "Erk\n", + "\n", + "Erk\n", + "\n", + "\n", + "\n", + "Akt\n", + "\n", + "Akt\n", + "\n", + "\n", + "\n", + "Erk->Akt\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKA\n", + "\n", + "PKA\n", + "\n", + "\n", + "\n", + "PKA->Erk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Mek\n", + "\n", + "Mek\n", + "\n", + "\n", + "\n", + "PKA->Mek\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKA->Akt\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Raf\n", + "\n", + "Raf\n", + "\n", + "\n", + "\n", + "PKA->Raf\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Jnk\n", + "\n", + "Jnk\n", + "\n", + "\n", + "\n", + "PKA->Jnk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "P38\n", + "\n", + "P38\n", + "\n", + "\n", + "\n", + "PKA->P38\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Mek->Erk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC\n", + "\n", + "PKC\n", + "\n", + "\n", + "\n", + "PKC->PKA\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC->Mek\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC->Raf\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC->Jnk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC->P38\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 2)\n", + "\n", + "('F', 2)\n", + "\n", + "\n", + "\n", + "('F', 2)->PKA\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Raf->Mek\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 0)\n", + "\n", + "('F', 0)\n", + "\n", + "\n", + "\n", + "('F', 0)->PKC\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 1)\n", + "\n", + "('F', 1)\n", + "\n", + "\n", + "\n", + "('F', 1)->Raf\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 3)\n", + "\n", + "('F', 3)\n", + "\n", + "\n", + "\n", + "('F', 3)->P38\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PIP3\n", + "\n", + "PIP3\n", + "\n", + "\n", + "\n", + "PIP2\n", + "\n", + "PIP2\n", + "\n", + "\n", + "\n", + "PIP3->PIP2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Plcg\n", + "\n", + "Plcg\n", + "\n", + "\n", + "\n", + "Plcg->PIP3\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Plcg->PIP2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 4)\n", + "\n", + "('F', 4)\n", + "\n", + "\n", + "\n", + "('F', 4)->PIP3\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 5)\n", + "\n", + "('F', 5)\n", + "\n", + "\n", + "\n", + "('F', 5)->Plcg\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "aug_graph = AugmentedGraph(incoming_directed_edges=ground_truth_G.copy())\n", + "\n", + "# add perturbations\n", + "for node in perturbations:\n", + " aug_graph.add_f_node({node}, domain=1)\n", + "\n", + "draw(aug_graph, name='Augmented Sachs Graph')" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "metadata": {}, + "outputs": [], + "source": [ + "node_mean_lims = [-1, 1]\n", + "node_std_lims = [0.01, 1.5]\n", + "edge_functions = [lambda x: x, lambda x: x**2]\n", + "edge_weight_lims = [-0.5, 0.5]\n", + "\n", + "n_domains = 5" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [], + "source": [ + "n_samples = 1000" + ] + }, + { + "cell_type": "code", + "execution_count": 38, + "metadata": {}, + "outputs": [], + "source": [ + "# convert graph to linear functional graph\n", + "aug_lin_graph = make_graph_linear_gaussian(\n", + " aug_graph,\n", + " node_mean_lims=node_mean_lims,\n", + " node_std_lims=node_std_lims,\n", + " edge_functions=edge_functions,\n", + " edge_weight_lims=edge_weight_lims,\n", + " random_state=seed,\n", + ")\n", + "md_lin_graph = sample_multidomain_lin_functions(\n", + " aug_lin_graph,\n", + " n_domains=n_domains,\n", + " node_mean_lims=node_mean_lims,\n", + " node_std_lims=node_std_lims,\n", + " edge_functions=edge_functions,\n", + " edge_weight_lims=edge_weight_lims,\n", + " random_state=seed,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 40, + "metadata": {}, + "outputs": [], + "source": [ + "domain_indices = []\n", + "intervention_targets = []\n", + "# mechanisms = []\n", + "data = []\n", + "\n", + "for idx, domain_id in enumerate(range(1, n_domains + 1)):\n", + " df = sample_from_graph(\n", + " md_lin_graph,\n", + " sample_func=\"multidomain\",\n", + " n_samples=n_samples,\n", + " n_jobs=1,\n", + " random_state=seed,\n", + " domain_id=domain_id,\n", + " )\n", + "\n", + " domain_indices.append(domain_id)\n", + " intervention_targets.append({})\n", + " data.append(df)\n", + "\n", + " for perturbation in perturbations:\n", + " int_graph = md_lin_graph.copy()\n", + "\n", + " # generate a soft-intervention\n", + " int_graph = apply_linear_soft_intervention(\n", + " int_graph, targets={perturbation}, random_state=seed\n", + " )\n", + "\n", + " # sample data from the intervention distribution\n", + " df = sample_from_graph(\n", + " int_graph,\n", + " sample_func=\"multidomain\",\n", + " n_samples=n_samples,\n", + " n_jobs=1,\n", + " random_state=seed,\n", + " domain_id=domain_id,\n", + " )\n", + " domain_indices.append(domain_id)\n", + " intervention_targets.append({perturbation})\n", + " data.append(df)" + ] + }, + { + "cell_type": "code", + "execution_count": 59, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[{'PKC'}, {'Raf'}, {'PKA'}, {'P38'}, {'PIP3'}, {'Plcg'}]\n", + "6\n" + ] + } + ], + "source": [ + "# generate single-domain data\n", + "single_domain_data = []\n", + "single_domain_targets = []\n", + "\n", + "seen_indices = set()\n", + "for targets in intervention_targets:\n", + " indices = [\n", + " idx\n", + " for idx in range(len(domain_indices))\n", + " if intervention_targets[idx] == targets\n", + " ]\n", + " if any(idx in seen_indices for idx in indices):\n", + " continue\n", + " for idx in indices:\n", + " seen_indices.add(idx)\n", + "\n", + " single_domain_data.append(pd.concat([data[idx] for idx in indices], axis=0))\n", + " if targets == {}:\n", + " continue\n", + " single_domain_targets.append(targets)\n", + "\n", + "print(single_domain_targets)\n", + "print(len(single_domain_targets))" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "## Analysis" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### With I-FCI" + ] + }, + { + "cell_type": "code", + "execution_count": 60, + "metadata": {}, + "outputs": [], + "source": [ + "context = (\n", + " make_context(create_using=InterventionalContextBuilder)\n", + " .variables(aug_graph.non_augmented_nodes)\n", + " # .obs_distribution(False)\n", + " .intervention_targets(single_domain_targets)\n", + " # .mechanisms([{\"x\": 1}, {\"x\": 2}])\n", + " .num_distributions(len(single_domain_data))\n", + " .build()\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 70, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 70, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# IFCI\n", + "learner = PsiFCI(\n", + " ci_estimator=FisherZCITest(),\n", + " cd_estimator=KernelCDTest(),\n", + " alpha=alpha,\n", + " known_intervention_targets=True,\n", + ")\n", + "learner.fit(\n", + " single_domain_data,\n", + " context,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 75, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "I-PAG\n", + "\n", + "\n", + "Erk\n", + "\n", + "Erk\n", + "\n", + "\n", + "\n", + "PKA\n", + "\n", + "PKA\n", + "\n", + "\n", + "\n", + "PKA->Erk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Mek\n", + "\n", + "Mek\n", + "\n", + "\n", + "\n", + "PIP2\n", + "\n", + "PIP2\n", + "\n", + "\n", + "\n", + "PKC\n", + "\n", + "PKC\n", + "\n", + "\n", + "\n", + "P38\n", + "\n", + "P38\n", + "\n", + "\n", + "\n", + "Raf\n", + "\n", + "Raf\n", + "\n", + "\n", + "\n", + "Plcg\n", + "\n", + "Plcg\n", + "\n", + "\n", + "\n", + "Jnk\n", + "\n", + "Jnk\n", + "\n", + "\n", + "\n", + "PIP3\n", + "\n", + "PIP3\n", + "\n", + "\n", + "\n", + "Akt\n", + "\n", + "Akt\n", + "\n", + "\n", + "\n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ifci_g = draw(learner.graph_.subgraph(aug_graph.non_augmented_nodes), name=\"I-PAG\")\n", + "ifci_g.render(\n", + " outfile=\"./sachs-ifci.pdf\",\n", + " format=\"pdf\",\n", + " cleanup=True,\n", + ")\n", + "display_svg(ifci_g)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### With S-FCI" + ] + }, + { + "cell_type": "code", + "execution_count": 76, + "metadata": {}, + "outputs": [], + "source": [ + "context = (\n", + " make_context(create_using=InterventionalContextBuilder)\n", + " .variables(aug_graph.non_augmented_nodes)\n", + " .num_distributions(len(data))\n", + " .build()\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 82, + "metadata": {}, + "outputs": [], + "source": [ + "# now learn the relationships\n", + "learner = SFCI(\n", + " ci_estimator=FisherZCITest(), cd_estimator=KernelCDTest(), alpha=alpha, debug=False,\n", + " n_jobs=-1\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 83, + "metadata": { + "tags": [] + }, + "outputs": [ + { + "ename": "KeyboardInterrupt", + "evalue": "", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mKeyboardInterrupt\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m/var/folders/6_/sl83qtkd68x3_mvfys07_6qm0000gn/T/ipykernel_19318/3270696463.py\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m learner.fit(\n\u001b[0m\u001b[1;32m 2\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 3\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 4\u001b[0m \u001b[0mdomain_indices\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mdomain_indices\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 5\u001b[0m \u001b[0mintervention_targets\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mintervention_targets\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/sfcialg.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, data, context, domain_indices, intervention_targets)\u001b[0m\n\u001b[1;32m 115\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mintervention_targets\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mintervention_targets\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 116\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 117\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 118\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 119\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m_apply_rule11\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mgraph\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mEquivalenceClass\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mContext\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m->\u001b[0m \u001b[0mTuple\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mbool\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mList\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/intervention.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, data, context)\u001b[0m\n\u001b[1;32m 187\u001b[0m )\n\u001b[1;32m 188\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 189\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 190\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 191\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m_apply_rule11\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mgraph\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mEquivalenceClass\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mContext\u001b[0m\u001b[0;34m)\u001b[0m \u001b[0;34m->\u001b[0m \u001b[0mTuple\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mbool\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mList\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/_classes.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, data, context)\u001b[0m\n\u001b[1;32m 215\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 216\u001b[0m \u001b[0;31m# learn skeleton graph and the separating sets per variable\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 217\u001b[0;31m graph, self.separating_sets_ = self.learn_skeleton(\n\u001b[0m\u001b[1;32m 218\u001b[0m \u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcontext_\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mseparating_sets_\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 219\u001b[0m )\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/sfcialg.py\u001b[0m in \u001b[0;36mlearn_skeleton\u001b[0;34m(self, data, context, sep_set)\u001b[0m\n\u001b[1;32m 72\u001b[0m \u001b[0mn_jobs\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mn_jobs\u001b[0m\u001b[0;34m,\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 73\u001b[0m )\n\u001b[0;32m---> 74\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mskeleton_learner_\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mdata\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdomain_indices\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mintervention_targets\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdebug\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mdebug\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 75\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 76\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcontext_\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mskeleton_learner_\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcontext_\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcopy\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/skeleton.py\u001b[0m in \u001b[0;36mfit\u001b[0;34m(self, data, context, domain_indices, intervention_targets, check_input, debug)\u001b[0m\n\u001b[1;32m 1827\u001b[0m \u001b[0;31m# initially learn the skeleton without using PDS information\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1828\u001b[0m \u001b[0;31m# apply algorithm to learn skeleton\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1829\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_fit\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobs_data\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mcontext\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mlist\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mcausal_nodes\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0maugmented_nodes\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0maugmented_nodes\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mdebug\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0mdebug\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1830\u001b[0m \u001b[0mcontext\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_prep_second_stage_skeleton\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1831\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/skeleton.py\u001b[0m in \u001b[0;36m_fit\u001b[0;34m(self, data, context, possible_x_nodes, skipped_y_nodes, skipped_z_nodes, debug)\u001b[0m\n\u001b[1;32m 1404\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0msecond_stage_condsel_method\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mnot\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1405\u001b[0m \u001b[0;31m# setup context for the second round-of learning\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1406\u001b[0;31m \u001b[0mcontext\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0msuper\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_prep_second_stage_skeleton\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mcontext\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1407\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1408\u001b[0m \u001b[0;31m# now compute all possibly d-separating sets and learn a better skeleton\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/skeleton.py\u001b[0m in \u001b[0;36m_prep_second_stage_skeleton\u001b[0;34m(self, context)\u001b[0m\n\u001b[1;32m 1150\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1151\u001b[0m \u001b[0;31m# orient colliders\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1152\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_orient_unshielded_triples\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mpag\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0msep_set\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 1153\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1154\u001b[0m \u001b[0;31m# convert the adjacency graph\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/Documents/dodiscover/dodiscover/constraint/skeleton.py\u001b[0m in \u001b[0;36m_orient_unshielded_triples\u001b[0;34m(self, graph, sep_set)\u001b[0m\n\u001b[1;32m 1132\u001b[0m \u001b[0;31m# Then check to see if 'u' is in the separating\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1133\u001b[0m \u001b[0;31m# set. If it is not, then there is a collider.\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m-> 1134\u001b[0;31m if v_j not in graph.neighbors(v_i) and not is_in_sep_set(\n\u001b[0m\u001b[1;32m 1135\u001b[0m \u001b[0mu\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0msep_set\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mv_i\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mv_j\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mmode\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;34m\"any\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 1136\u001b[0m ):\n", + "\u001b[0;32m~/Documents/pywhy-graphs/pywhy_graphs/networkx/classes/mixededge.py\u001b[0m in \u001b[0;36mneighbors\u001b[0;34m(self, n)\u001b[0m\n\u001b[1;32m 866\u001b[0m \u001b[0mnbrs\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 867\u001b[0m \u001b[0;32mfor\u001b[0m \u001b[0m_\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mG\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mget_graphs\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mitems\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 868\u001b[0;31m \u001b[0mnbrs\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mnbrs\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0munion\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mnx\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mall_neighbors\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mG\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mn\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 869\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0miter\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mnbrs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 870\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31mKeyboardInterrupt\u001b[0m: " + ] + } + ], + "source": [ + "learner.fit(\n", + " data,\n", + " context,\n", + " domain_indices=domain_indices,\n", + " intervention_targets=intervention_targets,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "draw(learner.graph_.subgraph(aug_graph.non_augmented_nodes))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pprint(learner.separating_sets_)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "# Generating Artificial Domains Via Dirichlet Perturbation\n", + "\n", + "Next, we do not assume we can specify the true SCM of the Sachs dataset. Instead of completely specifying the data-generating model for the Sachs dataset, we now apply perturbations to the dataset that is commonly used to evaluate algorithms, such as the FCI, I-FCI, $\\Psi$-FCI in a single-domain setting. \n", + "\n", + "To simulate a multi-domain setting from the data, we will generate a random graph from the ground-truth\n", + "that contains S-nodes. S-nodes are added randomly to simulate a change in mechanism. S-nodes will perturb the node it is pointing to with a dirichlet distribution that perturbs the discrete distribution of the protein expression levels. In order to maintain consistency of the change in domain, all descendants of the S-node will get perturbed slightly." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "11\n" + ] + } + ], + "source": [ + "print(ground_truth_G.number_of_nodes())" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Erk Akt PKA Mek Jnk PKC Raf P38 PIP3 PIP2 Plcg\n", + "0 1 0 1 0 1 1 0 0 1 0 0\n", + "1 2 1 1 0 1 1 0 0 1 0 1\n", + "2 1 0 1 0 1 0 0 0 2 0 0\n", + "3 1 0 2 0 0 0 1 0 0 0 0\n", + "4 1 0 2 0 0 0 1 0 2 0 0\n" + ] + } + ], + "source": [ + "print(data.head())" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "# initialize list of domain dataframes\n", + "domain_dfs = []\n", + "domain_dfs.append(df.copy())\n", + "all_s_nodes = []\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## (Optional) Choose Latent Confounders" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[]\n" + ] + } + ], + "source": [ + "# choose a random node to delete to add latent confounders\n", + "node_delete = rng.choice(ground_truth_G.nodes)\n", + "node_delete = []\n", + "print(node_delete)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Generate Second Domain Dataset" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "n_domains = 2" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "G = ground_truth_G.copy()" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2\n", + "Adding edge... ('S', 0) Jnk\n", + "Adding edge... ('S', 0) PIP2\n" + ] + } + ], + "source": [ + "sdx = 0\n", + "s_nodes = []\n", + "s_node_domains = collections.defaultdict(list)\n", + "\n", + "# first, add all the S-nodes representing differences across pairs of domains\n", + "for domains in combinations(range(1, n_domains+1), 2):\n", + " source_domain, target_domain = sorted(domains)\n", + "\n", + " # choose a random number of S-nodes to add between (source, target)\n", + " n_s_nodes = rng.integers(0, 3)\n", + " print(n_s_nodes)\n", + " s_nodes_pointer = rng.choice(G.nodes, size=n_s_nodes, replace=False)\n", + "\n", + " # now modify the function of the edge, S-nodes are pointing to\n", + " s_node = ('S', sdx)\n", + " s_nodes.append(s_node)\n", + " G.add_node(s_node, domain_ids=(source_domain, target_domain))\n", + " for node in s_nodes_pointer:\n", + " # the source domain is always the \"reference\" distribution, that is\n", + " # the one we keep fixed\n", + " G.add_edge(s_node, node)\n", + " print('Adding edge... ', s_node, node)\n", + " # mape each source to its target and corresponding S-nodes\n", + " s_node_domains[source_domain].append((target_domain, node, s_node))\n", + " sdx +=1" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Erk\n", + "\n", + "Erk\n", + "\n", + "\n", + "\n", + "Akt\n", + "\n", + "Akt\n", + "\n", + "\n", + "\n", + "Erk->Akt\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKA\n", + "\n", + "PKA\n", + "\n", + "\n", + "\n", + "PKA->Erk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Mek\n", + "\n", + "Mek\n", + "\n", + "\n", + "\n", + "PKA->Mek\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKA->Akt\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Raf\n", + "\n", + "Raf\n", + "\n", + "\n", + "\n", + "PKA->Raf\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Jnk\n", + "\n", + "Jnk\n", + "\n", + "\n", + "\n", + "PKA->Jnk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "P38\n", + "\n", + "P38\n", + "\n", + "\n", + "\n", + "PKA->P38\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Mek->Erk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC\n", + "\n", + "PKC\n", + "\n", + "\n", + "\n", + "PKC->PKA\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC->Mek\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC->Raf\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC->Jnk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PKC->P38\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Raf->Mek\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('S', 0)\n", + "\n", + "('S', 0)\n", + "\n", + "\n", + "\n", + "('S', 0)->Jnk\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PIP2\n", + "\n", + "PIP2\n", + "\n", + "\n", + "\n", + "('S', 0)->PIP2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "PIP3\n", + "\n", + "PIP3\n", + "\n", + "\n", + "\n", + "PIP3->PIP2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Plcg\n", + "\n", + "Plcg\n", + "\n", + "\n", + "\n", + "Plcg->PIP3\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "Plcg->PIP2\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 17, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "draw(G, direction='TD')" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": {}, + "outputs": [], + "source": [ + "# generate a random linear SCM dataset from the accepted ground-truth\n", + "ground_truth_G_lin_lab = make_graph_linear_gaussian(ground_truth_G, random_state=seed)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "non_s_nodes = set(ground_truth_G_lin_lab.nodes).difference(set(s_nodes))\n", + "# generate a dataset with invariances across domain\n", + "ground_truth_G_lin_hospital = make_graph_linear_gaussian(ground_truth_G, random_state=seed)\n", + "for node in non_s_nodes:\n", + " ground_truth_G_lin_hospital = set_node_attributes_with_G(ground_truth_G_lin_hospital, ground_truth_G_lin_hospital, node)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "data = []\n", + "domain_ids = []\n", + "intervention_targets = []" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "# sample both datasets\n", + "lab_dataset = linear.sample_from_graph(ground_truth_G_lin_lab, n_samples=1000,\n", + " n_jobs=n_jobs)\n", + "hospital_dataset = linear.sample_from_graph(ground_truth_G_lin_hospital, n_samples=1000,\n", + " n_jobs=n_jobs)\n", + "\n", + "data.extend([lab_dataset, hospital_dataset])\n", + "domain_ids.extend([1, 2])\n", + "intervention_targets.extend([set(), set()])" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# apply perturbations in the lab setting\n", + "for target in perturbations:\n", + " # apply soft intervention\n", + " G = apply_soft_intervention(ground_truth_G_lin_lab, targets={target}, random_state=seed)\n", + " df = linear.sample_from_graph(ground_truth_G_lin_hospital, n_samples=1000,\n", + " n_jobs=n_jobs)\n", + " \n", + " data.append(df)\n", + " domain_ids.append(1)\n", + " intervention_targets.append(target)\n", + " \n", + "# apply one perturbation in the hospital setting\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## $\\Psi$-FCI analysis\n", + "\n", + "Since, $\\Psi$-FCI is the most general learning algorithm that accounts for interventions and observational data, we will leverage this as a naive baseline." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [], + "source": [ + "n_distributions = len(data)" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": {}, + "outputs": [], + "source": [ + "ci_estimator = KernelCITest()\n", + "\n", + "# Since our data is entirely discrete, we can also use the G^2 test as our\n", + "# CD test.\n", + "cd_estimator = KernelCDTest()\n" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": {}, + "outputs": [], + "source": [ + "alpha = 0.05\n", + "learner = PsiFCI(ci_estimator=ci_estimator, cd_estimator=cd_estimator, alpha=alpha, n_jobs=-1)\n", + "\n", + "# create context with information about the interventions\n", + "ctx_builder = make_context(create_using=InterventionalContextBuilder)\n", + "ctx: Context = (\n", + " ctx_builder.variables(data=data[0]).num_distributions(n_distributions).obs_distribution(False).build()\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "learner = learner.fit(data, ctx)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "est_pag = learner.graph_\n", + "\n", + "print(f\"There are {len(est_pag.to_undirected().edges)} edges in the resulting PAG\")\n", + "\n", + "# %%\n", + "# Visualize the graph without the F-nodes\n", + "est_pag_no_fnodes = est_pag.subgraph(ctx.get_non_augmented_nodes())\n", + "dot_graph = draw(est_pag_no_fnodes, direction=\"LR\")\n", + "dot_graph.render(outfile=\"psi_pag.png\", view=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### S-FCI analysis\n", + "\n", + "Next, we run S-FCI to compare the outputs of the two algorithms" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Comparing Outputs" + ] + }, + { + "cell_type": "code", + "execution_count": 86, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[bnlearn] >Set node properties.\n", + "[bnlearn] >Set edge properties.\n", + "[bnlearn] >Plot based on Bayesian model\n" + ] + }, + { + "data": { + "image/png": "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kSPp27cvEydOpLCwMGR1iIiIiEhoKXgSERFpYDIzMwFCGjxZLBamTp2KaZpMnjyZYDAYslpEREREJHQUPImIiDQwGRkZWCwWkpOTQ1pHQkICDz74IAsWLOCdd94JaS0iIiIiEhoKnkRERBqYzMxMkpOTsdlsoS6FgQMHcvXVV/Pcc8+xdu3aUJcjIiIiIrVMwZOIiEgDE6oV7Q5m1KhRdO3alXHjxlFaWhrqckRERESkFil4EhERaWAyMzNDtqLdgdhsNqZPn05RURHTp0/HNM1QlyQiIiIitUTBk4iISANT10Y8AaSkpDBx4kS+++47vvjii1CXIyIiIiK1RMGTiIhIA+J2u3G73XUueAI49dRTufDCC3n00UfZunVrqMsRERERkVqg4ElERKQBycrKAqiTwRPA6NGjad68OePGjcPj8YS6HBERERGpYQqeREREGpCMjAyAOtXj6b+FhYXx8MMPk5GRwcyZM0NdjoiIiIjUMAVPIiIiDUhmZiZRUVFER0eHupSDateuHWPGjOHjjz/mxx9/DHU5IiIiIlKDFDyJiIg0IHsbixuGEepSDun888/nL3/5C1OnTq2aHigiIiIiDY+CJxERkQYkIyOjzvZ3+m+GYTBhwgRcLhcTJkzA7/eHuiQRERERqQEKnkRERBqQzMzMOtvf6X9FRUUxffp01q5dy4svvhjqckRERESkBih4EhERaSACgQDZ2dn1JngC6N69O7feeiuvv/46ixYtCnU5IiIiIlLNFDyJiIg0ELm5uQSDwXox1e6/XXnllQwYMIBJkyaxa9euUJcjIiIiItVIwZOIiEgDkZGRAVDvgieLxcKUKVMAmDRpEsFgMMQViYiIiEh1UfAkIiLSQGRmZmKxWEhOTg51KUcsPj6eadOmsWTJEt56661QlyMiIiIi1UTBk4iISAORmZlJcnIyNpst1KUclX79+nHNNdcwa9YsVq1aFepyRERERKQaKHgSERFpIDIyMurdNLv/ddNNN9G9e3cmTJhASUlJqMsRERERkWOk4ElERKSByMzMrPfBk81mY9q0aZSWljJ16lRM0wx1SSIiIiJyDBQ8iYiINBANIXgCSElJYeLEifz00098+umnoS5HRERERI6BgicREZEGwO1243a7adGiRahLqRYnn3wyF198MY8//jhbtmwJdTkiIiIicpQUPImIiDQAWVlZ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GqeddtoR1yQiciRe+WgOL/3rx2NeSOFYFRSVEBnu5LWpNx3R674cnuXLl/PAAw9QXFzMmDFjOPfccxvMXecnn3ySDz74gNdee01T1kWkTggGg6xevZq0tDTmzJlDRkYGERERDB48mNTUVE466aQ/veFdH61Zs4ZHH32UNWvWcPrpp3PHHXeQmJgY6rJEDkjBUz1TWl7J7I9+4uPvl+Lx+YiLjjzsHkgHYpom5ZVe3KUVJMRGcePfTuaCU/r86fSADRs2MH78eHJycrj77ru54IIL9vtQvXPnTv7617+Sl5dHs2bNyMrKonPnznz77bc1lsJPmzaN559/fp87zNu3bwegTZs2+20fCATIzc3F4XBw9dVXc9999zXINyYRCb2SskouvuspSssriY+pnQafG5Z8x6Zl3zPgvJtp0vyP1eyCpknurmKuOW8ot12u0L0mlJWV8fjjj/Pvf/+boUOHcv/99x92P666zOfzce2111JSUsI777yj90wRCQmv18uyZcuYM2cOc+fOpbCwkPj4+KopdH369Gmwo3527drFc889x7///W86derEPffcQ69evUJdlsghHW5WFLrbslJl4/ZsJj73EVt25hIZ5iQp/tinaRiGQWS4k3Cng8LiUh599d/MX76BB2658IC9R4LBIO+88w6zZs2iffv2vPPOOwcMdABatmzJgw8+yG233UZeXh6GYXDHHXfU6JvAgY6dmJjIjh07KCkpITo6ep/nrFYrzZo1IzMzk1deeYWwsDDGjx9fY/WJSOP146Lfyc7KYPVXswBwhkdxysj7sVj277NUUpjL3PcfByA8Oo5Trqre1yWLYeC02/hq3gquOX/oUTc4l4OLjIzkgQceYNiwYTz00ENceuml3H///QwbNizUpR0Tu93O9OnTueKKK5gxYwZTp05tMKO5RKRuKy0tZcGCBaSlpTF//nzKy8tp0aIFZ599NsOHD6d79+4Nqrfe//L5fHzwwQe8/PLL2Gw2xo0bxwUXXNCgr1kaHwVPIbZqYzpjn3yfvEI3TWOjsVXz6nQWi0GTuGgqPF5+Xr6Bux55m0fHjKBp3B9pZH5+Pg888ABLlizhqquu4pZbbvnTEOncc89l/vz5vPbaa/Tr149zzz23Wuv+X3a7fb8PwBEREURGRpKXl7dP8GSaJqWlpRQXFxMZGcn555/PyJEja7Q+EWmc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csstt3DxxRfz4YcfctVVV4W6HBFpgI7v3JrLzhzA65/NIyLcid1Wu1N7HQ4HsfFNKdyVjz9nFTHREXTs2JFt27bh8Xj29PHzequm3VksFvz+Pb0HR48eTd++fenbty+jR49my5YtzJ07l7lz5zJp0iQsFgu9evVi2LBhDBs2jObNm9fqtTV2AwYM4P333+fRRx9l4sSJzJ07l3HjxhETExPq0oA9AdmMGTO46qqrmDlzJuPGjQt1SSL1mmmabNu2rWolunXr1mGz2ejbty9jx45l6NCh6jFUzXbs2METTzzBggUL6N+/P08++SRt27YNdVkiDYqCp1qSlBBDfEwkGe7aXa5662/zyN2xjvjkNoRHx2Gx2qgoKSJvxzoCfi/N2vckpnlXundsecD99/YC8fl8Vf80TZOmTZvW6nUci5YtW3L++efz2muvcf755+vOhYjUiGsvSGXhb5tYvy2bpPgYLJbaG5VSXunBZneQenxLvt3wNRW2eMLDw2nXrl1V+GS1WgkEAlXT64LBIImJiftMQzYMgw4dOtChQweuu+468vLy+Pnnn5k7dy7PPvssM2fOpH379lUhVNeuXTX9oBa4XC6mTZvG0KFDefjhh7n00kuZNGkSgwYNCnVpALRv354xY8Ywffp0+vXrxymnnBLqkkTqlWAwyO+//141sik9PZ2IiAhOOukkrrzySk466SStHlkDysrKeOWVV3j33XdJSkriiSeeYOjQoXVqVKlIQ6HgqZYYhkG39i3Yum1brZ63RefemKbJruzt5O3chBkMYLU7CYtNIialE1FJ7dm9ezefffgWX7/5WFXD2b1BUzAYrFoJae+P3W7nmWee4bTTTqvVazkWN9xwA19++SVvvfUWo0aNCnU5ItIAhYc5mHTzhdz5yFvkFRaTmBCD5TA/vPr8PjZu3EjrVq2P+MtFhceLu7SCMwYfz/gbzuX6gs388MMPpKSk4HQ6adeuHdu3b6eiogK73Y5pmlU3EeLi4li8eDEDBgw44AftxMRELrroIi666CLKy8urGtl+9NFHvPrqqzRp0qSqL1Tfvn3VF6qGnXbaafTq1YspU6bwj3/8g4suuog77riDiIjQj1i+4IILWLJkCVOnTqVr166kpKSEuiSROs3r9bJs2bKqsKmwsJD4+HiGDh3K3XffrdfUGhQMBvnqq6949tlnKS8v56ab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iIiImrmJEyfi3LlzSE9PN9uYkZGROHr0KCoqKsw2JhERNT8MnoiIiIiImrkhQ4agdevWZm0yHhERAaPRiP3795ttTCIian4YPBERERERNXNSqRQTJkzArl27UF5ebpYxPTw8EBAQgH379pllPCIiap4YPBERERERtQBjx46FKIrYvn272caMjIzEwYMHodFozDYmERE1LwyeiIiIiIhaAFdXVwwdOhQbN26EIAhmGTMqKgo6nQ6HDh0yy3hERNT8MHgiIiIiImohYmJicOPGDRw+fNgs4/n4+MDPz4/b7YiI6I4YPBERERERtRABAQHw8/NDQkKC2caMjIzE/v37odPpzDYmERE1HwyeiIiIiIhaCIlEgokTJ+LAgQPIzc01y5hRUVGoqqrC8ePHzTIeERE1LwyeiIiIiIhakBEjRsDOzg6bNm0yy3gdO3aEr68v9u7da5bxiIioeWHwRERERETUgtjY2GDMmDHYsmWLWbbHSSQSREZGIjk5GUaj0QwVEhFRc8LgiYiIiIiohYmOjkZ5eTl+/fVXs4wXFRWFsrIypKWlmWU8IiJqPhg8ERERERG1ML6+vujfvz/i4+PNMp6/vz88PDx4uh0REd2CwRMRERERUQs0ceJEnD17FhkZGXUeSyqVIioqComJiRAEwQzVERFRc8HgiYiIiIioBRoyZAg8PT2RkJBglvGioqJQWFholiCLiIiaDwZPREREREQtkEwmw4QJE7Br1y6o1eo6jxcYGAgXFxdutyMioloYPBERERERtVDjxo2DIAjYvn17nceSSqUIDw/Hvn37IIqiGaojIqLmgMETEREREVEL5erqiqFDh2Ljxo1m6c0UFRWF69ev4/Lly2aojoiImgMGT0RERERELdjEiRNx/fp1HDlypM5j9e3bF3Z2dtxuR0REJgyeiIiIiIhasICAAPj5+ZmlybhSqURoaCgSExPNUBkRETUHDJ6IiIiIiFowiUSCmJgY7N+/H7m5uXUeLyoqCpmZmbh+/boZqiMioqaOwRMRERERUQv36KOPws7ODj/++GOdxxo4cCCsrKy46omIiAAweCIiIiIiavFsbGzw+OOPY8uWLdDpdHUea9CgQQyeiIgIAIMnIiIiIiICEB0djbKyMvz66691HisyMhJnzpxBYWGhGSojIqKmjMETERERERGhXbt2CAkJwcaNG+s8VmhoKGQyGZKSkupeGBERNWkMnoiIiIiICAAQExOD06dP48KFC3Uax8HBASEhIdi3b5+ZKiMioqaKwRMREREREQEAwsLC4Onpifj4+DqPFRkZiZMnT6KsrKzuhRERUZPF4ImIiIiIiAAAMpkMEyZMwK5du6BWq+s0VkREBERRxP+3d69BVtd5nuc/5+QVUhIERUUQofB+v6BoIXAOrV2KpYIcdqendnZ3dqe7t/tBRW9P9cZGd8RGbOxGbHRNR888mJnoqN6o3p2pmVkSFC0v1VqS3LwUqHgrtPBaWCqioJBckrydfaASRYGSQJ68vl4Pz///+59vPjGCt7/f/2zYsGGApgNgJBKeAACAI+6777709vbmkUceOa3nTJ48Odddd53jdgBjnPAEAAAcMXny5CxevDirVq1KX1/faT2rVCrlF7/4RQ4cODBA0wEw0ghPAADAUSqVSnbs2JHNmzef1nNKpVK6u7vz9NNPD9BkAIw0whMAAHCUa665JhdddNFpv2T83HPPzeWXX+64HcAYJjwBAABHKRQKqVQq2bRpU3bu3HlazyqVSnn66adz+PDhAZoOgJFEeAIAAI7xne98J+PGjcvq1atP6znlcjmHDh3Kc889N0CTATCSCE8AAMAxxo8fn7vvvjtr1qxJV1fXKT9n5syZmT17tuN2AGOU8AQAABxXpVLJZ599lqeeeuq0nlMul7Nhw4b09PQM0GQAjBTCEwAAcFwXXnhhbrrpprS1tZ3Wc8rlcjo6OvLCCy8M0GQAjBTCEwAA8LWWL1+eV155Jdu3bz/lZ1x00UWZNm2a43YAY5DwBAAAfK2FCxdm6tSpp7XrqVAopFwup729PX19fQM4HQDDnfAEAAB8rbq6uixbtiyPP/54Ojo6Tvk55XI5e/bsyauvvjqA0wEw3AlPAADAN1q6dGm6u7vzyCOPnPIzrrzyykyZMuW0X1QOwMgiPAEAAN9oypQpWbx4cdra2k75qFyxWEypVEp7e3uq1eoATwjAcCU8AQAAJ1SpVLJjx45s2bLllJ9RLpfz0Ucf5Ve/+tUATgbAcCY8AQAAJ3Tttddmzpw5p/WS8euvvz6tra1+3Q5gDBGeAACAEyoUCqlUKtmwYUN27tx5Ss+or6/PwoUL097ePsDTATBcCU8AAEC/3HnnnWlubs4DDzxwys8olUp599138+677w7gZAAMV8ITAADQL+PHj8/dd9+dNWvWpKur65SeMW/evIwbN86uJ4AxQngCAAD6rVKpZM+ePaccjhobGzN//nzveQIYI4QnAACg32bNmpUbb7wxK1euPOVnlEqlvPHGG/nwww8HcDIAhiPhCQAAOCkrVqzIyy+/nO3bt5/S+vnz56exsTHr1q0b2MEAGHaEJwAA4KQsWLAgZ599dlatWnVK68ePH5958+Y5bgcwBghPAADASamvr8+yZcvy2GOPpaOj45SeUSqV8vLLL2f37t0DPB0Aw4nwBAAAnLSlS5emu7s7jz766CmtX7BgQQqFQtavXz/AkwEwnAhPAADASTvrrLNSLpfT1taWarV60usnTpyYG2+80XE7gFFOeAIAAE5JpVLJr3/962zZsuWU1pdKpWzZsiX79u0b4MkAGC6EJwAA4JRcd911mT17dtra2k5p/aJFi9Lb25uNGzcO8GQADBfCEwAAcEoKhUJWrFiR9evXZ9euXSe9/uyzz87VV1/tuB3AKCY8AQAAp+yuu+5Kc3NzVq9efUrry+Vynn322Rw8eHCAJwNgOBCeAACAUzZ+/PjcfffdefDBB9Pd3X3S60ulUrq6uvLss8/WYDoAhprwBAAAnJbly5dnz549aW9vP+m1559/fi6++GLH7QBGKeEJAAA4LbNnz84NN9yQlStXntL6crmcjRs3pqura4AnA2CoCU8AAMBpW7FiRV566aW8+eabJ722XC7n4MGD2bJlSw0mA2AoCU8AAMBpW7hwYc4+++y0tbWd9NpZs2blggsucNwOYBQSngAAgNNWX1+fpUuX5vHHH8/+/ftPam2hUEi5XM66devS29tbowkBGArCEwAAMCCWLl2arq6uPProoye9tlwuZ+/evdm6dWsNJgNgqAhPAADAgDj77LNTKpXS1taWarV6Umsvu+yynHPOOY7bAYwywhMAADBgKpVK3nvvvTz//PMnte63j9v19fXVaDoABpvwBAAADJjrr78+s2fPPqWXjJdKpezatSvbtm2rwWQADAXhCQAAGDCFQiHLly/PunXrsmvXrpNae+211+bMM8903A5gFBGeAACAAbVkyZI0NzfngQceOKl1xWIxixYtSnt7+0m/IwqA4Ul4AgAABlRLS0uWLFmSBx98MN3d3Se1tlQqv9A0LQAAH4dJREFU5f33389bb71Vo+kAGEzCEwAAMOAqlUp2796d9vb2k1o3d+7cnHHGGSe9DoDhSXgCAAAG3OzZs3P99ddn1apVJ7WuoaEhCxYs8J4ngFFCeAIAAGqiUqnkxRdfPOljc6VSKW+99VZ27NhRo8kAGCzCEwAAUBOlUilTpkw56V1Pt9xyS5qamrJu3braDAbAoBGeAACAmqivr8+yZcvy2GOP5cCBA/1e19zcnG9/+9uO2wGMAsITAABQM0uXLk1nZ2ceffTRk1pXKpXy2muvZdeuXTWaDIDBIDwBAAA1M3Xq1CxatChtbW2pVqv9Xjd//vzU19f7dTuAEU54AgAAamrFihV5991388ILL/R7zYQJE3LTTTc5bgcwwglPAABATd1www2ZNWtW2traTmpduVzO1q1b89lnn9VoMgBqTXgCAABqqlAopFKppL29/aTe2bRgwYIkyYYNG2o1GgA1JjwBAAA1t2TJkjQ1NWXNmjX9XjN58uRce+21jtsBjGDCEwAAUHMtLS2566678sADD6Snp6ff68rlcjZv3pz9+/fXcDoAakV4AgAABkWlUsmnn36adevW9XtNqVRKd3d3nn766doNBkDNCE8AAMCgmDNnTq677rqsXLmy32vOOeecXH755XnqqadqOBkAtSI8AQAAg6ZSqeTFF1/M22+/3e81ixcvzjPPPJPOzs4aTgZALQhPAADAoCmVSpkyZUpWrVp1Ums6Ozvz3HPP1XAyAGpBeAIAAAZNQ0NDli5dmkcffTQHDhzo15oLLrgg3/rWt/y6HcAIJDwBAACDatmyZens7Mxjjz3W7zXlcjkbN25Md3d3DScDYKAJTwAAwKCaOnVqFi1alLa2tlSr1X6tKZVK6ejoyAsvvFDj6QAYSMITAAAw6CqVSt55551s3bq1X/dfdNFFOf/88x23AxhhhCcAAGDQ3XjjjbnwwguzcuXKft1fKBRSLpezbt269PX11Xg6AAaK8AQAAAy6QqGQSqWS9vb2fPLJJ/1aUyqVsmfPnrz88ss1ng6AgSI8AQAAQ2LJkiVpbGzMgw8+2K/7r7zyypx99tlpb2+v8WQADBThCQAAGBJnnHFG7rzzzjzwwAPp6ek54f3FYjGlUilr167t90vJARhawhMAADBkKpVKPv3006xfv75f95dKpezcuTNvvPFGjScDYCAITwAAwJC56KKLcu211/b7JePXX399WltbHbcDGCGEJwAAYEitWLEiL7zwQt55550T3ltXV5dFixZl7dq1gzAZAKdLeAIAAIZUqVTK5MmTs2rVqn7f/9577/UrVAEwtIQnAABgSDU0NGTp0qV55JFHcvDgwRPef/PNN2f8+PGO2wGMAMITAAAw5JYtW5bOzs489thjJ7y3sbEx8+fPd9wOYAQQngAAgCF3zjnnZOHChWlra0u1Wj3h/eVyOb/61a/y4YcfDsJ0AJwq4QkAABgWKpVK3n777WzduvWE9956661pbGx03A5gmBOeAACAYWHu3LmZOXNm2traTnjv+PHjM2/ePMftAIY54QkAABgWCoVCKpVK1q5dm08//fSE95fL5bzyyiv9uheAoSE8AQAAw8aSJUvS0NCQBx988IT3LliwIIVCIevWrav9YACcEuEJAAAYNiZMmJC77rorDzzwQHp6er7x3tbW1tx4442O2wEMY8ITAAAwrCxfvjyffPJJNmzYcMJ7Fy9enOeffz779u0bhMkAOFnCEwAAMKxcfPHFueaaa/r1kvGFCxemWq32K1IBMPiEJwAAYNipVCrZsmVL3n333W+876yzzsrVV1/tuB3AMCU8AQAAw065XM7kyZP7teupXC7nueeey8GDBwdhMgBOhvAEAAAMO42NjbnvvvvyyCOPnDAolUqldHV15Zlnnhmk6QDoL+EJAAAYlpYtW5bOzs48/vjj33jftGnTcskllzhuBzAMCU8AAMCwdO6552bBggVpa2tLtVr9xnvL5XI2bdqUrq6uQZoOgP4QngAAgGGrUqnkrbfeyksvvfSN95XL5Rw8eDC/+MUvBmcwAPpFeAIAAIatuXPn5oILLjjhS8ZnzZqVCy+8MO3t7YM0GQD9ITwBAADDVrFYzPLly7N27drs3r37G+8tl8tZt25dent7B2k6AE5EeAIAAIa17373u6mrq8uaNWu+8b5SqZR9+/blxRdfHJzBADgh4QkAABjWJkyYkDvvvDOrV6/+xt1Ml156ac4991zH7QCGEeEJAAAY9lasWJFdu3Zl/fr1X3tPoVBIuVzO2rVr09fXN4jTAfB1hCcAAGDYu/jii3P11Vdn1apV33hfqVTKp59+mtdee22QJgPgmwhPAADAiFCpVLJ58+a89957X3vPNddck8mTJztuBzBMCE8AAMCIsHjx4px55pnfuOupWCxm0aJFWbt2barV6iBOB8DxCE8AAMCI0NjYmPvuuy8//elPc/Dgwa+9r1wu54MPPsibb745iNMBcDzCEwAAMGIsW7Yshw4dys9+9rOvveeGG27IhAkTHLcDGAaEJwAAYMQ477zzMn/+/LS1tX3tUbqGhobcdtttWbt27SBPB8DvEp4AAIARZcWKFXnzzTfzyiuvfO095XI5b7/9dnbs2DGIkwHwu4QnAABgRLnpppsyY8aMrFy58mvvmTdvXpqbm+16AhhiwhMAADCiFIvFVCqVPPXUU9mzZ89x72lubs6tt94qPAEMMeEJAAAYce6+++7U1dVlzZo1X3vP4sWLs23btuzcuXPwBgPgKMITAAAw4rS2tuY73/lOVq1ald7e3uPeM3/+/DQ0NGTdunWDOxwARwhPAADAiFSpVLJr165s3LjxuNdbWlpy0003OW4HMISEJwAAYES69NJLc9VVV33jS8bL5XJeeumlr30XFAC1JTwBAAAjVqVSyebNm/PrX//6uNcXLFiQJFm/fv1gjgXAl4QnAABgxPq93/u9TJo0KatWrTru9TPPPDPXXXdd2tvbB3kyABLhCQAAGMEaGxtz33335ac//WkOHTp03HvK5XI2b96cjo6OQZ4OAOEJAAAY0ZYtW5YDBw7kZz/72XGvl0ql9PT0ZNOmTYM8GQDCEwAAMKJNmzYtt912W9ra2lKtVo+5PnXq1Fx55ZWO2wEMAeEJAAAY8SqVSrZv355XX331uNfL5XKefvrpdHZ2DvJkAGOb8AQAAIx4N998c2bMmJGVK1ce9/qiRYty+PDhPPvss4M8GcDYJjwBAAAjXrFYzPLly/Pzn/88e/bsOeb6BRdckDlz5mTt2rVDMB3A2CU8AQAAo8J3v/vdFIvFrFmz5rjXy+VyNmzYkO7u7sEdDGAME54AAIBRobW1Nd/5zneyevXq9PX1HXO9VCrlwIED2bJlyxBMBzA2CU8AAMCoUalU8vHHH2fjxo3HXJszZ05mzJjh1+0ABpHwBAAAjBqXXXZZrrzyyrS1tR1zrVAopFQqZd26dcfdEQXAwBOeAACAUaVSqeS5557Ljh07jrlWLpfz2Wef5aWXXhr8wQDGIOEJAAAYVW6//fZMmjQpq1atOuba5ZdfnqlTpzpuBzBIhCcAAGBUaWxszL333puHH344hw4dOupasVjMokWLsnbt2lSr1SGaEGDsEJ4AAIBR5/7778+BAwfyj//4j8dcK5fL+fjjj/P6668PwWQAY4vwBAAAjDrTpk3L/Pnzs3LlymN2Nl133XWZOHFi1q5dO0TTAYwdwhMAADAqVSqVbN++Pa+++upRn9fV1WXhwoWO2wEMAuEJAAAYlebNm5fzzz//uC8ZX7x4cXbs2JF33313CCYDGDuEJwAAYFQqFotZvnx5nnzyyezZs+eoa3Pnzs348eMdtwOoMeEJAAAYte65554UCoU89NBDR33e2NiY2267TXgCqDHhCQAAGLUmTpyY3//938/q1avT19d31LVyuZzt27fngw8+GKLpAEY/4QkAABjVKpVKdu7cmU2bNh31+S233JLGxka7ngBqSHgCAABGtcsvvzxXXHFF2trajvp8/PjxueWWW9Le3j5EkwGMfsITAAAw6lUqlTz77LPZsWPHUZ+Xy+W88sor+eSTT4ZoMoDRTXgCAABGvTvuuCOtra1ZvXr1UZ/fdtttqaury7p164ZmMIBRTngCAABGvcbGxtx33315+OGH09nZeeTz1tbWzJ0713E7gBoRngAAgDHh/vvvz/79+/PEE08c9Xm5XM7zzz+fvXv3DtFkAKOX8AQAAIwJ559/fm699dasXLky1Wr1yOcLFy5MtVrN+vXrh3A6gNFJeAIAAMaMFStW5I033shrr7125LMpU6bkmmuucdwOoAaEJwAAYMy45ZZbMm3atLS1tR31eblcznPPPZeDBw8O0WQAo5PwBAAAjBnFYjHLly/Pk08+mc8+++zI54sWLUp3d3c2bdo0hNMBjD7CEwAAMKbce++9KRQKefjhh498Nm3atFx22WWO2wEMMOEJAAAYUyZOnJg77rgjq1atSl9f35HPS6VSNm3alK6uriGcDmB0EZ4AAIAxZ8WKFfnoo4/y9NNPH/msXC7n0KFDee6554ZwMoDRRXgCAADGnMsvvzyXX375US8Zv/DCCzNr1qysXbt2CCcDGF2EJwAAYEyqVCp55pln8pvf/ObIZ6VSKRs2bEhPT88QTgYweghPAADAmHTHHXektbU1q1atOvJZuVzOvn378uKLLw7hZACjh/AEAACMSU1NTbn33nvz8MMPp7OzM0lyySWXZNq0aY7bAQwQ4QkAABiz7r///nR0dOSJJ55IkhQKhZRKpbS3tx/1i3cAnBrhCQAAGLOmT5+eW265JW1tbalWq0m+OG63e/fuvPbaa0M8HcDIJzwBAABj2ooVK/L6669n27ZtSZKrrroqU6ZMcdwOYAAITwAAwJh26623Ztq0aVm5cmWSpFgsZtGiRVm7du2RXVAAnBrhCQAAGNOKxWKWL1+eJ598Mp9//nmSL47bffjhh9m+ffvQDgcwwglPAADAmHfPPfckSR5++OEkyQ033JAJEyY4bgdwmoQnAABgzJs0aVJuv/32rFq1Kn19famvr8+CBQvS3t4+1KMBjGjCEwAAQJJKpZIPP/wwzzzzTJIvjtu98847ee+994Z2MIARTHgCAABIcsUVV+Syyy478pLxefPmZdy4cXY9AZwG4QkAACBJoVBIpVLJs88+m9/85jdpamrKt7/9beEJ4DQITwAAAF+64447MmHChKxevTrJF8fttm3blo8++miIJwMYmYQnAACALzU3N+eee+7JQw89lMOHD+fb3/52Ghoa7HoCOEXCEwAAwG+5//77s2/fvjzxxBNpaWnJzTffLDwBnCLhCQAA4LfMmDEjt956a9ra2pJ8cdzupZdeyp49e4Z4MoCRR3gCAAD4HcuXL8+2bduybdu2LFiwIIVCIevWrRvqsQBGHOEJAADgd8yfPz/nnXdeVq5cmUmTJuWGG25w3A7gFAhPAAAAv6NYLOb+++/PE088kb1796ZUKmXz5s3p6Og4ck+1Ws3mzZvz13/911mzZk3+/M//PD/84Q+HcGqA4Ud4AgAAOI5777031Wo1Dz30UBYtWpTe3t5s3LgxHR0d+dGPfpS5c+dm8eLF+au/+qv84R/+Yf7+7/8+r7zyylCPDTCs1A/1AAAAAMPRmWeemdtvvz2rVq3K9773vZxzzjn5sz/7s3zyySfZt29fqtVqkqRQKGTKlCn5/PPPc9FFFw3x1ADDi/AEAADwNW6++eb83d/9Xc4///zs2rUrfX19KRaLqa+vT11dXfr6+pIkTU1NSZKZM2cO5bgAw47wBAAA8KVqtZo333wzDz74YH7yk59k+/btOXz4cJKkvr4+1Wo1DQ0NqaurO2ZtsVjMhRdeOMgTAwxvwhMAAECSd955J3/wB3+Q119/PQcOHDiyu6mhoSG9vb1paGhItVpNb2/vkfBUrVZTX1+fw4cPp6mpyY4ngN8hPAEAAGPa4cOH88ADD+RHP/pRtm7dmu7u7tTX16exsTGFQiFJ0tvbm56entTV1aWnpyfVajWFQiHVajV1dXXp6upKc3Nzpk+fPsR/DcDw4lftAACAMe3RRx/NX/7lX+all17K9OnT09zcfOTF4V+pq6tLb29visUv/gn11budki+O4HV1dWXmzJmpr/f/9gF+m/AEAACMaUuWLMnSpUszbty4VKvVzJo1Kw0NDenu7j4SoL4KSl/tdPoqPH2146m7uzuXXHLJkP0NAMOV8AQAAIxpTU1N+eEPf5i/+Iu/SF9fX/bu3ZuZM2ceFZ8KhUKKxWJ6enpSLBbT29t7ZH19fX2KxWJmz549hH8FwPAkPAEAAGNesVjM97///fzN3/xNWlpa8vnnn+eCCy44Kj7V1dWlWq0eddzuqyDV19fnF+0AjkN4AgAA+NL999+fH//4xznvvPPy+eefZ/r06amvr093d3eKxeJRx+y+2vVUrVbT2NiYs8+Zlo93780Hu/bk491703Ggcyj/FIBhoVD93bfmHce+ffsyceLE7N27N62trYMxFwAAwJDZvn17/uiP/ijbtm1LS0tLPvroo/T09KRQKKS3tzd1dXWpa5mSxglTM+GsaWloOSvTZ81JUjhyNK+uWMg5UybmijnTc9HMc3PFnOm5cs70IzumAEay/rYi4QkAAOA4du3alT/5kz/Jpk2b0tTUlF27dqU3xTRNnpmW8y5N/fhJKRSKKRSLqa8rZOaMGSkWCymkkGqq6e2t5nB3d3p6vtgZ1dBQn4tnnpuli29M+eYrM6GleYj/QoBTJzwBAACcpoMHD+YHP/hBHlyzJnWT56SvdUaK9c2pVqup9hxOte+L3U8TJ07MrFmzvvY51Wo1nV3d2bf/UKrVaqZMmpB/ds9tqfz+zWmorxvEvwhgYPS3FdUP4kwAAAAjyvjx4/On//P/mld3j8+vd+1LobcnPV0HU632HdnZlCTNzd+8e6lQKGRcU2PGNTWmp7c3ezsO5l//h8ez8YU38oN/fndmT586GH8OwKBzuBgAAOA4qtVq/vNjz+RP/49/yMFqU86ZMinF9KSuWDwqOiVJY2Njv59bX1eXKZMmZNKE8dny2jv5F//bj/LAz7fU4k8AGHJ2PAEAAPyOvr6+/F3b2vy/D21MsVjIuVMmfrFraVxT3t/xfvqqfan2fRGeqtVqGpv6H56+0tTYkHPPmpjde/fnX/340ezbfyj/7b23pVAoDPSfAzBk7HgCAAD4LdVqNf/3A+vy/zy0IQ0NdZk88YwjMah1Qmtmz56d8ePGp5AvPqummqbGplP6rkKhkLMmTUhdsZC/W/lUfvLo0wP2dwAMB8ITAADAb3lk/db8+MH1aayvz8Qzxh9zfdy4cZn9rdlpbm5OsVBMY0NjGhobTus7J7W2pK6umH///z2VJ5999bSeBTCcOGoHAADwpQ927cm//c9PpK+vmomTjo1OX2lsaMwll16SQwcPZcKECQPy3We2tmTXnn35N//xZ7n20pk5+0y/KA6MfHY8AQAA5Iv3Ov3NPzyWTz/fnymTzjjh/fV19QMWnb4yZdIZ2fnp3vyb//CzVKvVEy8AGOaEJwAAgCSPb3o5T2/dnklnjE+xODT/VKorFtPaMi5PPffLtG/eNiQzAAwk4QkAABjz+vr68l8eezbVVDOu+eR/oe6bfPrB23nk3/0gv9r8RL/ubxnXlJ7e3vyXx5+16wkY8YQnAABgzHvx9ffy1vsfZ2LLuGOuPfLvfpD2//TXgzpP6xnj8su3fpNfvv3BoH4vwEATngAAgDHvp+teTE9Pb5pO89fpBsq4psYc7u7Jo+u3DvUoAKdFeAIAAMa0jgOd2fjCGxnX3JhCoTDU4yRJCoVCmhsb8vPnXsvhrp6hHgfglNUP9QAAAABD6e33P86hzu60nnHsMbvj+dXmJ/Lm809m3r1/nMMH9uXtre3Z//knaWgal/O+dU0uu+Wu1NWfeOdU9+FD2fL4P2TPh+/mivn3ZNbV84+6Pq6pMQcOHc57H+zKJbOmndLfBjDU7HgCAADGtLd2fJye3t401Ned1Lr3Xn06r6xblTMmn5uZV96ShqZxee/VTXm5ve2EazsP7Msza/59Pt/561x/+x8cE52SpLGxPt3dPXnr/Y9Pai6A4cSOJwAAYEx7a8fOJDnpY3af/ubN3Fb5fs44c2qSpPfm7mxY+bf58K2XcvmtS9LcMvG46/Z//kl+8dMfpbvzYOYu+ec5e8bFx72vWCgkhULe2iE8ASOXHU8AAMCY9t4Hn6RYPPl/Gs26ev6R6JQkdfUNmTbn2qRazd5Pjv9rdJ9//H6eefDfprenK/Pu/eOvjU6/7f2Pdp/0bADDhR1PAADAmNbZ1X1KLxWfePb0Yz4bd8YXu5y6Dx865tqej97NOy9vSNO4ltx09/+YMyadfcLvKBYKOXS4+6RnAxgu7HgCAADGtL5qNafyW3b1jc3HfFb4cudUtdp3zLV9n36Q3u7DaT1rWlpap/R/vr5jnwUwUghPAADAmNbU0JBqtVrz77nwqm9nxmU3Zec7r+XFJ/9T+vp6T7imWq2mqdFBFWDk8l8wAABgTDvrzDPSOyi7igq5etHyFAqF7Nj2iyTJdbf/kxSLX/9retVqNWedOWEQZgOoDTueAACAMe3imeclp/COp1NRKBRy1cL7c8Hl8/LR2y9n6zfsfPpqF9acC84dlNkAasGOJwAAYEz71oxzUkjS29uXurra/7/5L+LTshQKhfz6l8+mWq3m+jv+6TE7n3q+nGfOBefUfCaAWrHjCQAAGNMumnlumhob0tl17K/HVb88gvdNx+FORaFQyJULlmbmlbdm5zuv5sUn/mP6eo/e+dR5uDtNjQ2ZM0N4AkYuO54AAIAx7dyzJmbOjHPyy7d/k5ZxTUddO9y5P0nSOK7lyGeX3HRHLrnpjuM+a8alczPj0rlHfXbW+d/K3X/yw2PuLRQKuWrB0ly1YOlxn3Ww83BuvGJWJrW2HPc6wEhgxxMAADCmFQqF3Lf4hiRJz+/sOvr43V8mSSZNvWBQZ+ru+WKO+xbPPcGdAMOb8AQAAIx55ZuvzOSJLdm3/1CS5M0XnsqLT/4kr21Yk7r6xsy8Yt6gzrN3/8GcM2ViFtx46aB+L8BAE54AAIAxb0JLc+5acF0Od/ekt7cv77y0Prveez1nTZ+TW+7944xvnTxos/T09Kanpzf3lm5Ic2PDoH0vQC14xxMAAECS/+a787P2F7/Mh7s+y+//D//7kMxQrVbz6d6OzJo+Nf/VnbcMyQwAA8mOJwAAgCRntrbkz/7Znamvr8v+g51DMkPHgc40NTTkX/53SzKhpXlIZgAYSMITAADAlxbccGnuuu2adBzoTFd3z6B+9+Gu7hzsPJylv3djbrrqW4P63QC1IjwBAAB8qVAo5PvfuzPXXTYzu/fuT3fP4MSnru6e7Nl3IDdfPSf/04rfG5TvBBgMwhMAAMBvmdDSnP/rz/5JrpozI59+tj+Hu2obnzq7urN77/5cf9mF+T+/vyLjxzXV9PsABpPwBAAA8DumTDojf/u/fC9zr5qdz/btz+f7DqRarQ7od1Sr1ezZuz+fdxzMrddelL/5wfcy8YzxA/odAENNeAIAADiOSa0t+du/+F7+xfJyCsVCdu7eO2DvfTrc1ZOdu/emsaE+f/pf355/9S//qZeJA6NS/VAPAAAAMFw1NzXmDyvl3HrtRfnhjx/J6+98mGqS1pbmjGtqTKFQ6PezqtVqDnZ2pePgoRRSyLWXzMwP/vsluWTWtNr9AQBDrFDtx37Rffv2ZeLEidm7d29aW1sHYy4AAIBhpbOrO+2/+GXWPPV8XnvrNznc3ZP6uro0NdanubEhDfV1R4WoarWa7p7edHZ153BXd3p6+9Lc2JCrL56R+xbPzaK5l6WxwV4AYGTqbysSngAAAE5CtVrNtnc+yD9ueiVb33gv7+/c80VY6uk9JjzV19elqbEhM8+bkusvuzDfmX9NLr7wvJPaKQUwHPW3FcnrAAAAJ6FQKOSKb03PFd+aniTpONCZt9//OL/+8JN0Hu5OV09vGuvr0tzcmNnnT83sGVPT4pfqgDFKeAIAADgNE1qac+2lM3PtpTOHehSAYcev2gEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADVR35+bqtVqkmTfvn01HQYAAACA4e+rRvRVM/o6/QpPHR0dSZIZM2ac5lgAAAAAjBYdHR2ZOHHi114vVE+UppL09fXlww8/zIQJE1IoFAZ0QAAAAABGlmq1mo6OjkybNi3F4te/yalf4QkAAAAATpaXiwMAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE8ITAAAAADUhPAEAAABQE/8/ulWCrX9HV28AAAAASUVORK5CYII=\n", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + " Raf Mek Plcg PIP2 PIP3 Erk Akt PKA PKC P38 Jnk INT\n", + "0 1 1 1 2 3 2 1 3 1 2 1 8\n", + "1 1 1 1 1 3 3 2 3 1 2 1 8\n", + "2 1 1 2 2 3 2 1 3 2 1 1 8\n", + "3 1 1 1 1 3 2 1 3 1 3 1 8\n", + "4 1 1 1 1 3 2 1 3 1 1 1 8\n", + "(5400, 12)\n" + ] + } + ], + "source": [ + "# use pooch to download robustly from a url\n", + "url = \"https://www.bnlearn.com/book-crc/code/sachs.interventional.txt.gz\"\n", + "file_path = pooch.retrieve(\n", + " url=url,\n", + " known_hash=\"md5:39ee257f7eeb94cb60e6177cf80c9544\",\n", + ")\n", + "\n", + "df = pd.read_csv(file_path, delimiter=\" \")\n", + "\n", + "# the ground-truth dag is shown here: XXX: comment in when errors are fixed\n", + "ground_truth_dag = bnlearn.import_DAG(\"sachs\", verbose=False)\n", + "fig = bnlearn.plot(ground_truth_dag)\n", + "\n", + "# .. note::\n", + "# The Sachs dataset has previously been preprocessed, and the steps are described\n", + "# in bnlearn, at the web-page https://www.bnlearn.com/research/sachs05/.\n", + "print(df.head())\n", + "print(df.shape)" + ] + }, + { + "cell_type": "code", + "execution_count": 113, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "6 6 6\n", + "Graph with 26 nodes and 325 edges\n", + "There are 284 edges in the resulting PAG\n" + ] + }, + { + "data": { + "text/plain": [ + "'s_pag.png'" + ] + }, + "execution_count": 113, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# %%\n", + "# Preprocess the dataset\n", + "# ----------------------\n", + "# Since the data is one dataframe, we need to process it into a form\n", + "# that is acceptable by dodiscover's :class:`constraint.PsiFCI` algorithm. We\n", + "# will form a list of separate dataframes.\n", + "unique_ints = df[\"INT\"].unique()\n", + "\n", + "# get the list of intervention targets and list of dataframe associated with each intervention\n", + "intervention_targets = [df.columns[idx] for idx in unique_ints]\n", + "data_cols = [col for col in df.columns if col != \"INT\"]\n", + "data = []\n", + "domain_ids = np.array([0, 0, 0, 0, 0, 1])\n", + "for interv_idx in unique_ints:\n", + " _data = df[df[\"INT\"] == interv_idx][data_cols]\n", + " data.append(_data)\n", + "\n", + "print(len(data), len(intervention_targets), len(domain_ids))\n", + "# %%\n", + "# Setup constraint-based learner\n", + "# ------------------------------\n", + "# Since we have access to interventional data, the causal discovery algorithm\n", + "# we will use that leverages CI and CD tests to estimate causal constraints\n", + "# is the Psi-FCI algorithm :footcite:`Jaber2020causal`.\n", + "\n", + "# Our dataset is comprised of discrete valued data, so we will utilize the\n", + "# G^2 (Chi-square) CI test.\n", + "ci_estimator = GSquareCITest(data_type=\"discrete\")\n", + "\n", + "# Since our data is entirely discrete, we can also use the G^2 test as our\n", + "# CD test.\n", + "cd_estimator = GSquareCITest(data_type=\"discrete\")\n", + "\n", + "alpha = 0.8\n", + "learner = SFCI(\n", + " ci_estimator=ci_estimator, cd_estimator=cd_estimator, alpha=alpha, n_jobs=-1\n", + ")\n", + "\n", + "# create context with information about the interventions\n", + "ctx_builder = make_context(create_using=InterventionalContextBuilder)\n", + "ctx: Context = ctx_builder.variables(data=data[0]).num_distributions(len(data)).build()\n", + "\n", + "print(ctx.init_graph)\n", + "\n", + "# %%\n", + "# Run the learning process\n", + "# ------------------------\n", + "# We have setup our causal context and causal discovery learner, so we will now\n", + "# run the algorithm using the :meth:`constraint.PsiFCI.fit` API, which is similar to scikit-learn's\n", + "# `fit` design. All fitted attributes contain an underscore at the end.\n", + "learner = learner.fit(\n", + " data, ctx, domain_indices=domain_ids, intervention_targets=intervention_targets\n", + ")\n", + "\n", + "# %%\n", + "# Analyze the results\n", + "# ===================\n", + "# Now that we have learned the graph, we will show it here. Note differences and similarities\n", + "# to the ground-truth DAG that is \"assumed\". Moreover, note that this reproduces Supplementary\n", + "# Figure 8 in :footcite:`Jaber2020causal`.\n", + "est_pag = learner.graph_\n", + "\n", + "print(f\"There are {len(est_pag.to_undirected().edges)} edges in the resulting PAG\")\n", + "\n", + "# %%\n", + "# Visualize the full graph including the F-node\n", + "# dot_graph = draw(est_pag, direction=\"LR\")\n", + "# dot_graph.render(outfile=\"_pag_full.png\", view=True)\n", + "\n", + "# %%\n", + "# Visualize the graph without the F-nodes\n", + "est_pag_no_fnodes = est_pag.subgraph(data_cols)\n", + "dot_graph = draw(est_pag_no_fnodes, direction=\"LR\")\n", + "dot_graph.render(outfile=\"s_pag.png\", view=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 114, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Graph with 26 nodes and 325 edges\n", + "[('F', 0), ('F', 1), ('F', 2), ('F', 3), ('F', 4), ('F', 5), ('F', 6), ('F', 7), ('F', 8), ('F', 9), ('F', 10), ('F', 11), ('F', 12), ('F', 13), ('F', 14)]\n", + "There are 167 edges in the resulting PAG\n" + ] + }, + { + "data": { + "text/plain": [ + "'psi_pag.png'" + ] + }, + "execution_count": 114, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Setup constraint-based learner\n", + "# ------------------------------\n", + "# Since we have access to interventional data, the causal discovery algorithm\n", + "# we will use that leverages CI and CD tests to estimate causal constraints\n", + "# is the Psi-FCI algorithm :footcite:`Jaber2020causal`.\n", + "\n", + "# Our dataset is comprised of discrete valued data, so we will utilize the\n", + "# G^2 (Chi-square) CI test.\n", + "ci_estimator = GSquareCITest(data_type=\"discrete\")\n", + "\n", + "# Since our data is entirely discrete, we can also use the G^2 test as our\n", + "# CD test.\n", + "cd_estimator = GSquareCITest(data_type=\"discrete\")\n", + "\n", + "alpha = 0.05\n", + "learner = PsiFCI(\n", + " ci_estimator=ci_estimator, cd_estimator=cd_estimator, alpha=alpha, n_jobs=-1\n", + ")\n", + "\n", + "# create context with information about the interventions\n", + "ctx_builder = make_context(create_using=InterventionalContextBuilder)\n", + "ctx: Context = (\n", + " ctx_builder.variables(data=data[0])\n", + " .num_distributions(6)\n", + " .obs_distribution(False)\n", + " .build()\n", + ")\n", + "\n", + "print(ctx.init_graph)\n", + "print(ctx.f_nodes)\n", + "\n", + "# %%\n", + "# Run the learning process\n", + "# ------------------------\n", + "# We have setup our causal context and causal discovery learner, so we will now\n", + "# run the algorithm using the :meth:`constraint.PsiFCI.fit` API, which is similar to scikit-learn's\n", + "# `fit` design. All fitted attributes contain an underscore at the end.\n", + "learner = learner.fit(data, ctx)\n", + "\n", + "# %%\n", + "# Analyze the results\n", + "# ===================\n", + "# Now that we have learned the graph, we will show it here. Note differences and similarities\n", + "# to the ground-truth DAG that is \"assumed\". Moreover, note that this reproduces Supplementary\n", + "# Figure 8 in :footcite:`Jaber2020causal`.\n", + "est_pag = learner.graph_\n", + "\n", + "print(f\"There are {len(est_pag.to_undirected().edges)} edges in the resulting PAG\")\n", + "\n", + "# %%\n", + "# Visualize the full graph including the F-node\n", + "dot_graph = draw(est_pag, direction=\"LR\")\n", + "dot_graph.render(outfile=\"psi_pag_full.png\", view=True)\n", + "\n", + "# %%\n", + "# Visualize the graph without the F-nodes\n", + "est_pag_no_fnodes = est_pag.subgraph(ctx.get_non_augmented_nodes())\n", + "dot_graph = draw(est_pag_no_fnodes, direction=\"LR\")\n", + "dot_graph.render(outfile=\"psi_pag.png\", view=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pywhy-discover", + "language": "python", + "name": "pywhy-discover" + }, + "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.13" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/doc/tutorials/multi-domain/random-graph-analysis.ipynb b/doc/tutorials/multi-domain/random-graph-analysis.ipynb new file mode 100644 index 000000000..6b23b84f5 --- /dev/null +++ b/doc/tutorials/multi-domain/random-graph-analysis.ipynb @@ -0,0 +1,802 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "dd37974a-507e-406f-af4a-d927248ca73f", + "metadata": {}, + "source": [ + "# Random ER-G" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "a2e4ad0a-86e4-44d6-9e2e-ebce00716483", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + "%load_ext lab_black" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "735b60f6-a74f-4a1b-85d5-e3fec97c0b62", + "metadata": {}, + "outputs": [], + "source": [ + "from IPython.display import display_svg" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "5404ecd8-17bf-4fe6-93fd-dbc2fffb8df0", + "metadata": {}, + "outputs": [], + "source": [ + "from pprint import pprint\n", + "import numpy as np\n", + "import scipy\n", + "import pandas as pd\n", + "import collections\n", + "from itertools import combinations\n", + "import networkx as nx\n", + "import pywhy_graphs as pgraphs\n", + "from pywhy_graphs import AugmentedGraph\n", + "from pywhy_graphs.functional import (\n", + " make_graph_linear_gaussian,\n", + " make_graph_multidomain,\n", + " set_node_attributes_with_G,\n", + " apply_linear_soft_intervention,\n", + " sample_multidomain_lin_functions,\n", + ")\n", + "from pywhy_graphs.viz import draw\n", + "from pywhy_graphs.simulate import simulate_random_er_dag\n", + "\n", + "from dodiscover.cd import KernelCDTest\n", + "from dodiscover.ci import KernelCITest, FisherZCITest, Oracle\n", + "from dodiscover.constraint.skeleton import LearnMultiDomainSkeleton\n", + "from dodiscover.constraint.utils import dummy_sample\n", + "from dodiscover.datasets import sample_from_graph\n", + "\n", + "from dodiscover.cd.residual import ResidualCDTest\n", + "\n", + "from dodiscover import (\n", + " SFCI,\n", + " PsiFCI,\n", + " FCI,\n", + " Context,\n", + " make_context,\n", + " InterventionalContextBuilder,\n", + ")\n", + "from dodiscover.metrics import (\n", + " structure_hamming_dist,\n", + " confusion_matrix_networks,\n", + " # structure_hamming_dist_ec,\n", + ")\n", + "\n", + "import seaborn as sns\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "514e7715-203e-4362-8eda-6569a029a849", + "metadata": {}, + "outputs": [], + "source": [ + "seed = 12345" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "3c9267d5-6845-404d-b2b1-8c991f0a516a", + "metadata": {}, + "outputs": [], + "source": [ + "alpha = 0.05" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "07d7b26f-a025-42da-989a-2a245a86a440", + "metadata": {}, + "outputs": [], + "source": [ + "def convert_md_data_to_sd(data, domain_indices, intervention_targets):\n", + " # generate single-domain data\n", + " single_domain_data = []\n", + " single_domain_targets = []\n", + "\n", + " seen_indices = set()\n", + " for targets in intervention_targets:\n", + " indices = [\n", + " idx\n", + " for idx in range(len(domain_indices))\n", + " if intervention_targets[idx] == targets\n", + " ]\n", + " if any(idx in seen_indices for idx in indices):\n", + " continue\n", + " for idx in indices:\n", + " seen_indices.add(idx)\n", + "\n", + " single_domain_data.append(pd.concat([data[idx] for idx in indices], axis=0))\n", + " if targets == {}:\n", + " continue\n", + " single_domain_targets.append(targets)\n", + " return single_domain_data, single_domain_targets" + ] + }, + { + "cell_type": "markdown", + "id": "859b79a5-980e-47e2-b36b-0635807216b6", + "metadata": {}, + "source": [ + "# Run Experiment" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "03e8ed34-ef56-496f-923a-04ba4e10894c", + "metadata": {}, + "outputs": [], + "source": [ + "rng = np.random.default_rng(seed)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "48a7f96a-d9e6-4503-8c68-b368466f0dee", + "metadata": {}, + "outputs": [], + "source": [ + "alpha = 0.2" + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "bc0b93f4-c7f4-49d9-8af9-4fece68c33f3", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[5 6 7 8 9]\n", + "[0.3 0.35 0.4 0.45 0.5 ]\n" + ] + } + ], + "source": [ + "node_mean_lims = [-1, 1]\n", + "node_std_lims = [2.0, 3.5]\n", + "edge_functions = [\n", + " lambda x: x,\n", + " # lambda x: x**2,\n", + "]\n", + "edge_weight_lims = [1, 5]\n", + "n_node_grid = np.arange(5, 10)\n", + "p_grid = np.linspace(0.3, 0.5, 5)\n", + "n_domains_grid = np.arange(2, 10)\n", + "n_repeats = 1\n", + "\n", + "max_cond_set_size = 3\n", + "\n", + "n_samples = 2000\n", + "ratio_interventions = 0.5\n", + "n_jobs = -1\n", + "print(n_node_grid)\n", + "print(p_grid)" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "bde56fd2-6adc-4181-8efb-acdc2945ead8", + "metadata": { + "tags": [] + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[[4 0]\n", + " [2 4]] [[4 0]\n", + " [2 4]]\n", + "2.0 2.0\n", + "[[4 0]\n", + " [2 4]]\n", + "[[4 0]\n", + " [2 4]]\n" + ] + } + ], + "source": [ + "sfci_results = collections.defaultdict(list)\n", + "ifci_results = collections.defaultdict(list)\n", + "parameters = collections.defaultdict(list)\n", + "\n", + "for n_nodes in n_node_grid:\n", + " n_interventions = int(ratio_interventions * n_nodes)\n", + "\n", + " for p in p_grid:\n", + " # simulate random ER-graph\n", + " G = simulate_random_er_dag(n_nodes=n_nodes, p=p, seed=seed, ensure_acyclic=True)\n", + " aug_graph = AugmentedGraph(incoming_directed_edges=G)\n", + " # convert graph into a multi-domain linear graph\n", + " aug_lin_graph = make_graph_linear_gaussian(\n", + " aug_graph,\n", + " node_mean_lims=node_mean_lims,\n", + " node_std_lims=node_std_lims,\n", + " edge_functions=edge_functions,\n", + " edge_weight_lims=edge_weight_lims,\n", + " random_state=seed,\n", + " )\n", + " non_augmented_nodes = set(G.nodes)\n", + "\n", + " for n_domains in n_domains_grid:\n", + " md_lin_graph = sample_multidomain_lin_functions(\n", + " aug_lin_graph,\n", + " n_domains=n_domains,\n", + " node_mean_lims=node_mean_lims,\n", + " node_std_lims=node_std_lims,\n", + " edge_functions=edge_functions,\n", + " edge_weight_lims=edge_weight_lims,\n", + " random_state=seed,\n", + " )\n", + " # keep a copy of the ground-truth graph\n", + " groundtruth_graph = md_lin_graph.copy()\n", + "\n", + " # now generate interventions\n", + " for idx in range(n_repeats):\n", + " domain_indices = []\n", + " intervention_targets = []\n", + " mechanisms = []\n", + " data = []\n", + "\n", + " # generate observational distirbution\n", + " for jdx, domain_id in enumerate(range(1, n_domains + 1)):\n", + " df = sample_from_graph(\n", + " md_lin_graph,\n", + " sample_func=\"multidomain\",\n", + " n_samples=int(n_samples),\n", + " n_jobs=1,\n", + " random_state=seed + idx,\n", + " domain_id=domain_id,\n", + " )\n", + "\n", + " domain_indices.append(domain_id)\n", + " intervention_targets.append({})\n", + " mechanisms.append(jdx)\n", + " data.append(df)\n", + " for jdx in range(n_interventions):\n", + " # get random node to perturb\n", + " perturbation = set(\n", + " rng.choice(\n", + " list(aug_lin_graph.non_augmented_nodes),\n", + " size=1,\n", + " replace=False,\n", + " )\n", + " )\n", + "\n", + " int_graph = md_lin_graph.copy()\n", + "\n", + " # generate a soft-intervention\n", + " int_graph = apply_linear_soft_intervention(\n", + " int_graph, targets=perturbation, random_state=seed + idx\n", + " )\n", + "\n", + " # sample data from the intervention distribution\n", + " df = sample_from_graph(\n", + " int_graph,\n", + " sample_func=\"multidomain\",\n", + " n_samples=n_samples,\n", + " n_jobs=1,\n", + " random_state=seed + idx,\n", + " domain_id=domain_id,\n", + " )\n", + " domain_indices.append(domain_id)\n", + " intervention_targets.append(perturbation)\n", + " mechanisms.append(jdx)\n", + " data.append(df)\n", + "\n", + " # run S-FCI\n", + " context = (\n", + " make_context(create_using=InterventionalContextBuilder)\n", + " .variables(aug_graph.non_augmented_nodes)\n", + " .num_distributions(len(data))\n", + " .build()\n", + " )\n", + " # now learn the relationships\n", + " learner = SFCI(\n", + " ci_estimator=FisherZCITest(),\n", + " # cd_estimator=KernelCDTest(null_reps=100),\n", + " cd_estimator=ResidualCDTest(),\n", + " alpha=alpha,\n", + " debug=False,\n", + " n_jobs=n_jobs,\n", + " max_cond_set_size=max_cond_set_size,\n", + " )\n", + " learner.fit(\n", + " data,\n", + " context,\n", + " domain_indices=domain_indices,\n", + " intervention_targets=intervention_targets,\n", + " )\n", + " spag = learner.graph_\n", + "\n", + " # run I-FCI\n", + " single_domain_data, single_domain_targets = convert_md_data_to_sd(\n", + " data, domain_indices, intervention_targets\n", + " )\n", + " context = (\n", + " make_context(create_using=InterventionalContextBuilder)\n", + " .variables(aug_graph.non_augmented_nodes)\n", + " # .obs_distribution(False)\n", + " .intervention_targets(single_domain_targets)\n", + " # .mechanisms([{\"x\": 1}, {\"x\": 2}])\n", + " .num_distributions(len(single_domain_data))\n", + " .build()\n", + " )\n", + " learner = PsiFCI(\n", + " ci_estimator=FisherZCITest(),\n", + " # cd_estimator=KernelCDTest(null_reps=100),\n", + " cd_estimator=ResidualCDTest(),\n", + " alpha=alpha,\n", + " known_intervention_targets=True,\n", + " n_jobs=n_jobs,\n", + " max_cond_set_size=max_cond_set_size,\n", + " )\n", + " learner.fit(\n", + " single_domain_data,\n", + " context,\n", + " )\n", + " ipag = learner.graph_\n", + "\n", + " # analyze skeleton\n", + " cm_ipag = confusion_matrix_networks(\n", + " aug_lin_graph.to_undirected(),\n", + " ipag.subgraph(non_augmented_nodes).to_undirected(),\n", + " )\n", + " cm_spag = confusion_matrix_networks(\n", + " aug_lin_graph.to_undirected(),\n", + " spag.subgraph(non_augmented_nodes).to_undirected(),\n", + " )\n", + "\n", + " # analyze directionality orietnations\n", + " shd_ipag = structure_hamming_dist(\n", + " aug_lin_graph.sub_directed_graph(),\n", + " ipag.subgraph(non_augmented_nodes).sub_directed_graph(),\n", + " )\n", + " shd_spag = structure_hamming_dist(\n", + " aug_lin_graph.sub_directed_graph(),\n", + " spag.subgraph(non_augmented_nodes).sub_directed_graph(),\n", + " )\n", + "\n", + " print(cm_ipag, cm_spag)\n", + " print(shd_ipag, shd_spag)\n", + "\n", + " sfci_results[\"shd\"].append(shd_spag)\n", + " sfci_results[\"cm_skel\"].append(cm_spag)\n", + " ifci_results[\"shd\"].append(shd_ipag)\n", + " ifci_results[\"cm_skel\"].append(cm_ipag)\n", + "\n", + " # analyze skeleton\n", + " cm_ipag = confusion_matrix_networks(\n", + " aug_lin_graph.sub_directed_graph(),\n", + " ipag.subgraph(non_augmented_nodes).sub_directed_graph(),\n", + " )\n", + " cm_spag = confusion_matrix_networks(\n", + " aug_lin_graph.sub_directed_graph(),\n", + " spag.subgraph(non_augmented_nodes).sub_directed_graph(),\n", + " )\n", + " print(cm_ipag)\n", + " print(cm_spag)\n", + " sfci_results[\"cm_direct\"].append(cm_spag)\n", + " ifci_results[\"shd\"].append(shd_ipag)\n", + " ifci_results[\"cm_direct\"].append(cm_ipag)\n", + " parameters[\"idx\"].append(idx)\n", + " parameters[\"n_domains\"].append(n_domains)\n", + " parameters[\"p_edge\"].append(p)\n", + " parameters[\"n_nodes\"].append(n_nodes)\n", + " # break\n", + " break\n", + " break\n", + " break" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "3c6cae59-7337-4d8b-bb8a-748e68a33d09", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2\n", + "[{}, {}, {4}, {1}]\n", + "4\n", + "0.3 5\n" + ] + } + ], + "source": [ + "print(n_domains)\n", + "print(intervention_targets)\n", + "print(len(data))\n", + "print(p, n_nodes)" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "13c6c922-0ba5-4a59-88ee-a8090f9eb5a2", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[[4 0]\n", + " [2 4]]\n", + "[[4 0]\n", + " [2 4]]\n" + ] + } + ], + "source": [ + "# analyze directionality orietnations\n", + "# shd_ipag = structure_hamming_dist_ec(\n", + "# aug_lin_graph,\n", + "# ipag.subgraph(non_augmented_nodes),\n", + "# )\n", + "# shd_spag = structure_hamming_dist_ec(\n", + "# aug_lin_graph,\n", + "# spag.subgraph(non_augmented_nodes),\n", + "# )\n", + "\n", + "# analyze skeleton\n", + "cm_ipag = confusion_matrix_networks(\n", + " aug_lin_graph.sub_directed_graph(),\n", + " ipag.subgraph(non_augmented_nodes).sub_directed_graph(),\n", + ")\n", + "cm_spag = confusion_matrix_networks(\n", + " aug_lin_graph.sub_directed_graph(),\n", + " spag.subgraph(non_augmented_nodes).sub_directed_graph(),\n", + ")\n", + "\n", + "print(cm_ipag)\n", + "print(cm_spag)\n", + "# print(shd_ipag, shd_spag)" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "5d9d610c-4cce-4a5e-a1ba-ac9dedf68ad8", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "0\n", + "\n", + "0\n", + "\n", + "\n", + "\n", + "2\n", + "\n", + "2\n", + "\n", + "\n", + "\n", + "0->2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "4\n", + "\n", + "4\n", + "\n", + "\n", + "\n", + "0->4\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "1\n", + "\n", + "1\n", + "\n", + "\n", + "\n", + "1->2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "1->4\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "2->4\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "3\n", + "\n", + "3\n", + "\n", + "\n", + "\n", + "3->4\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 32, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "draw(groundtruth_graph)" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "5aef1b41-0c62-4d35-a10d-213fca31b9a0", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "4\n", + "\n", + "4\n", + "\n", + "\n", + "\n", + "0\n", + "\n", + "0\n", + "\n", + "\n", + "\n", + "4->0\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "1\n", + "\n", + "1\n", + "\n", + "\n", + "\n", + "4->1\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "2\n", + "\n", + "2\n", + "\n", + "\n", + "\n", + "4->2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "3\n", + "\n", + "3\n", + "\n", + "\n", + "\n", + "4->3\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "draw(ipag.subgraph(non_augmented_nodes))" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "2b48e391-59f0-42be-b21c-27382cde56c3", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "4\n", + "\n", + "4\n", + "\n", + "\n", + "\n", + "0\n", + "\n", + "0\n", + "\n", + "\n", + "\n", + "4->0\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "1\n", + "\n", + "1\n", + "\n", + "\n", + "\n", + "4->1\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "2\n", + "\n", + "2\n", + "\n", + "\n", + "\n", + "4->2\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "3\n", + "\n", + "3\n", + "\n", + "\n", + "\n", + "4->3\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 34, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "draw(spag.subgraph(non_augmented_nodes))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "da273420-b573-4323-8a5b-f039330aa104", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pywhy-discover", + "language": "python", + "name": "pywhy-discover" + }, + "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.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/doc/whats_new/v0.1.rst b/doc/whats_new/v0.1.rst index 2c35eb48f..f8a44341a 100644 --- a/doc/whats_new/v0.1.rst +++ b/doc/whats_new/v0.1.rst @@ -50,6 +50,7 @@ Changelog - |Feature| Add a suite of general categorical data CI tests, by `Adam Li`_ (:pr:`128`) - |Feature| Implement CAM, SCORE, DAS, NoGAM algorithms in ``dodiscover.toporder`` submodule (:pr:`129`) - |Feature| Add Psi-FCI and I-FCI algorithm for handling soft-interventional data, :class:`dodiscover.constraint.PsiFCI` by `Adam Li`_ (:pr:`111`) +- |Feature| Add S-FCI algorithm for handling observational and interventional data from different environments, :class:`dodiscover.constraint.PsiFCI` by `Adam Li`_ (:pr:`140`) Code and Documentation Contributors ----------------------------------- diff --git a/dodiscover/__init__.py b/dodiscover/__init__.py index 5fad479d3..6f386bcb2 100644 --- a/dodiscover/__init__.py +++ b/dodiscover/__init__.py @@ -1,10 +1,9 @@ -from . import cd # noqa: F401 from . import ci # noqa: F401 from . import metrics # noqa: F401 from . import toporder from . import testdata from ._protocol import EquivalenceClass, Graph from ._version import __version__ # noqa: F401 -from .constraint import FCI, PC, PsiFCI +from .constraint import FCI, PC, SFCI, PsiFCI from .context import Context from .context_builder import ContextBuilder, InterventionalContextBuilder, make_context diff --git a/dodiscover/cd/base.py b/dodiscover/cd/base.py index dff161fc8..d69f1d3dc 100644 --- a/dodiscover/cd/base.py +++ b/dodiscover/cd/base.py @@ -39,7 +39,9 @@ def _check_test_input( if x_vars is not None and any(col not in df.columns for col in x_vars): raise ValueError("The x variables are not all in the DataFrame.") if any(col not in df.columns for col in y_vars): - raise ValueError("The y variables are not all in the DataFrame.") + raise ValueError( + f"The y variables, {y_vars} are not all in the DataFrame: {df.columns}" + ) if group_col_var not in df.columns: raise ValueError(f"The group column {group_col_var} is not in the DataFrame.") @@ -99,6 +101,11 @@ def test( def _compute_propensity_scores(self, group_ind, **kwargs): if self.propensity_model is None: K = kwargs.get("K") + if K is None: + # use the empirical propensities + empirical_propensity = group_ind.sum() / len(group_ind) + self.propensity_est_ = np.ones(len(group_ind)) * empirical_propensity + return self.propensity_est_ # compute a default penalty term if using a kernel matrix if K.shape[0] == K.shape[1]: @@ -131,12 +138,19 @@ def _compute_propensity_scores(self, group_ind, **kwargs): return self.propensity_est_ @abstractmethod - def _statistic(self, X: ArrayLike, Y: ArrayLike, group_ind: ArrayLike) -> float: + def _statistic( + self, Y: ArrayLike, group_ind: ArrayLike, X: Optional[ArrayLike] = None + ) -> float: """Abstract method for computing the test statistic.""" pass def compute_null( - self, e_hat: ArrayLike, X: ArrayLike, Y: ArrayLike, null_reps: int = 1000, random_state=None + self, + e_hat: ArrayLike, + Y: ArrayLike, + X: Optional[ArrayLike] = None, + null_reps: int = 1000, + random_state=None, ) -> ArrayLike: """Estimate null distribution using propensity weights. @@ -144,10 +158,10 @@ def compute_null( ---------- e_hat : Array-like of shape (n_samples,) The predicted propensity score for ``group_ind == 1``. - X : Array-Like of shape (n_samples, n_features_x) - The X (covariates) array. Y : Array-Like of shape (n_samples, n_features_y) The Y (outcomes) array. + X : Array-Like of shape (n_samples, n_features_x) + The X (covariates) array. null_reps : int, optional Number of times to sample null, by default 1000. random_state : int, optional @@ -160,14 +174,14 @@ def compute_null( """ rng = np.random.default_rng(random_state) - n_samps = X.shape[0] + n_samps = Y.shape[0] # compute the test statistic on the conditionally permuted # dataset, where each group label is resampled for each sample # according to its propensity score null_dist = Parallel(n_jobs=self.n_jobs)( [ - delayed(self._statistic)(X, Y, rng.binomial(1, e_hat, size=n_samps)) + delayed(self._statistic)(Y, rng.binomial(1, e_hat, size=n_samps), X) for _ in range(null_reps) ] ) diff --git a/dodiscover/cd/bregman.py b/dodiscover/cd/bregman.py index 5ff4bf8d3..9b91c9189 100644 --- a/dodiscover/cd/bregman.py +++ b/dodiscover/cd/bregman.py @@ -111,7 +111,10 @@ def test( pvalue = (1.0 + np.sum(null_dist >= conditional_div)) / (1 + self.null_reps) return conditional_div, pvalue - def _statistic(self, X: ArrayLike, Y: ArrayLike, group_ind: ArrayLike) -> float: + def _statistic(self, X: ArrayLike, Y: ArrayLike, group_ind: ArrayLike) -> float: # type: ignore + # def _statistic( + # self, Y: ArrayLike, group_ind: ArrayLike, X: Optional[ArrayLike] = None + # ) -> float: first_group = group_ind == 0 second_group = group_ind == 1 X1 = X[first_group, :] @@ -119,6 +122,10 @@ def _statistic(self, X: ArrayLike, Y: ArrayLike, group_ind: ArrayLike) -> float: Y1 = Y[first_group, :] Y2 = Y[second_group, :] + print("dodiscover") + print(X1.shape, X2.shape, Y1.shape, Y2.shape) + print(X1[:2], X2[:2], Y1[:2], Y2[:2]) + # first compute the centered correntropy matrices, C_xy^1 Cx1y1 = corrent_matrix(np.hstack((X1, Y1)), kwidth=self.kwidth) Cx2y2 = corrent_matrix(np.hstack((X2, Y2)), kwidth=self.kwidth) diff --git a/dodiscover/cd/kernel_test.py b/dodiscover/cd/kernel_test.py index 2f8bd292a..5b11b4c31 100644 --- a/dodiscover/cd/kernel_test.py +++ b/dodiscover/cd/kernel_test.py @@ -1,4 +1,4 @@ -from typing import Set, Tuple +from typing import Optional, Set, Tuple import numpy as np import pandas as pd @@ -109,13 +109,6 @@ def test( # compute kernel for the X and Y data X = df[x_cols].to_numpy() Y = df[y_cols].to_numpy() - K, sigma_x = compute_kernel( - X, - distance_metric=self.distance_metric, - metric=self.metric, - kwidth=self.kwidth_x, - n_jobs=self.n_jobs, - ) L, sigma_y = compute_kernel( Y, distance_metric=self.distance_metric, @@ -123,20 +116,34 @@ def test( kwidth=self.kwidth_y, n_jobs=self.n_jobs, ) - # store fitted attributes - self.kwidth_x_ = sigma_x self.kwidth_y_ = sigma_y + if len(x_vars) != 0: + K, sigma_x = compute_kernel( + X, + distance_metric=self.distance_metric, + metric=self.metric, + kwidth=self.kwidth_x, + n_jobs=self.n_jobs, + ) + else: + K = None + sigma_x = None + self.kwidth_x_ = sigma_x + # compute the statistic - stat = self._statistic(K, L, group_ind) + stat = self._statistic(L, group_ind, K=K) # compute propensity scores - e_hat = self._compute_propensity_scores(group_ind, K=K) + if K is None: + e_hat = self._compute_propensity_scores(group_ind) + else: + e_hat = self._compute_propensity_scores(group_ind, K=K) # now compute null distribution null_dist = self.compute_null( - e_hat, K, L, null_reps=self.null_reps, random_state=self.random_state + e_hat, L, X=K, null_reps=self.null_reps, random_state=self.random_state ) self.null_dist_ = null_dist @@ -144,11 +151,10 @@ def test( pvalue = (1 + np.sum(null_dist >= stat)) / (1 + self.null_reps) return stat, pvalue - def _statistic(self, K: ArrayLike, L: ArrayLike, group_ind: ArrayLike) -> float: - n_samples = len(K) - - # compute W matrices from K and z - W0, W1 = self._compute_inverse_kernel(K, group_ind) + def _statistic( + self, L: ArrayLike, group_ind: ArrayLike, K: Optional[ArrayLike] = None + ) -> float: + n_samples = len(L) # compute L kernels first_mask = np.array(1 - group_ind, dtype=bool) @@ -157,16 +163,25 @@ def _statistic(self, K: ArrayLike, L: ArrayLike, group_ind: ArrayLike) -> float: L1 = L[np.ix_(second_mask, second_mask)] L01 = L[np.ix_(first_mask, second_mask)] - # compute the final test statistic - K0 = K[:, first_mask] - K1 = K[:, second_mask] - KW0 = K0 @ W0 - KW1 = K1 @ W1 + if K is not None: + # compute W matrices from K and z + W0, W1 = self._compute_inverse_kernel(K, group_ind) + + # compute the final test statistic + K0 = K[:, first_mask] + K1 = K[:, second_mask] + KW0 = K0 @ W0 + KW1 = K1 @ W1 - # compute the three terms in Lemma 4.4 - first_term = np.trace(KW0.T @ KW0 @ L0) - second_term = np.trace(KW1.T @ KW0 @ L01) - third_term = np.trace(KW1.T @ KW1 @ L1) + # compute the three terms in Lemma 4.4 + first_term = np.trace(KW0.T @ KW0 @ L0) + second_term = np.trace(KW1.T @ KW0 @ L01) + third_term = np.trace(KW1.T @ KW1 @ L1) + else: + # compute the three terms in Lemma 4.4 + first_term = np.trace(L0) + second_term = np.trace(L01) + third_term = np.trace(L1) # compute final statistic stat = (first_term - 2 * second_term + third_term) / n_samples diff --git a/dodiscover/ci/categorical_test.py b/dodiscover/ci/categorical_test.py index 733f62c38..ddd7f103d 100644 --- a/dodiscover/ci/categorical_test.py +++ b/dodiscover/ci/categorical_test.py @@ -8,21 +8,21 @@ import numpy as np import pandas as pd -from numpy.typing import ArrayLike from scipy import stats from dodiscover.ci.base import BaseConditionalIndependenceTest from dodiscover.typing import Column +# This is a modified function taken from pgmpy: License MIT def power_divergence( - X: ArrayLike, Y: ArrayLike, Z: ArrayLike, data: pd.DataFrame, lambda_: str = "cressie-read" + X, Y, Z, data: pd.DataFrame, lambda_: str = "cressie-read" ) -> Tuple[float, float, int]: - """ - Computes the Cressie-Read power divergence statistic [1]. The null hypothesis - for the test is X is independent of Y given Z. A lot of the frequency comparison - based statistics (eg. chi-square, G-test etc) belong to power divergence family, - and are special cases of this test. + """Computes the Cressie-Read power divergence statistic [1]. + + The null hypothesis for the test is X is independent of Y given Z. A lot of the + frequency comparison based statistics (eg. chi-square, G-test etc) belong to + power divergence family, and are special cases of this test. Parameters ---------- @@ -100,7 +100,7 @@ def power_divergence( else: chi = 0 dof = 0 - for z_state, df in data.groupby(Z): + for idx, (z_state, df) in enumerate(data.groupby(Z[0] if len(Z) == 1 else Z)): try: # Note: The fill value is set to 1e-7 to avoid the following error: # where there are not enough samples in the data, which results in a nan pvalue @@ -112,7 +112,7 @@ def power_divergence( # If one of the values is 0 in the 2x2 table. if isinstance(z_state, str): logging.info( - f"Skipping the test {X} \u27C2 {Y} | {Z[0]}={z_state}. Not enough samples" + f"Skipping the test {X} \u27C2 {Y} | {Z[idx]}={z_state}. Not enough samples" ) else: z_str = ", ".join([f"{var}={state}" for var, state in zip(Z, z_state)]) @@ -170,6 +170,9 @@ def test( ) -> Tuple[float, float]: x_vars = reduce(lambda x: x, x_vars) # type: ignore y_vars = reduce(lambda x: x, y_vars) # type: ignore + + if z_covariates is not None and len(z_covariates) > 0: + z_covariates = list(z_covariates) stat, pvalue, dof = power_divergence( x_vars, y_vars, z_covariates, data=df, lambda_=self.lambda_ ) diff --git a/dodiscover/ci/kernel_utils.py b/dodiscover/ci/kernel_utils.py index 88130102d..fa8e78670 100644 --- a/dodiscover/ci/kernel_utils.py +++ b/dodiscover/ci/kernel_utils.py @@ -200,6 +200,14 @@ def compute_kernel( med : float The estimated kernel width. """ + + def check_2d(X): + if X is not None and X.ndim == 1: + X = X.reshape(-1, 1) + return X + + X = check_2d(X) + # if the width of the kernel is not set, then use the median trick to set the # kernel width based on the data X if kwidth is None: diff --git a/dodiscover/ci/oracle.py b/dodiscover/ci/oracle.py index 67c5984a9..be9b77b55 100644 --- a/dodiscover/ci/oracle.py +++ b/dodiscover/ci/oracle.py @@ -33,6 +33,7 @@ def test( x_vars: Set[Column], y_vars: Set[Column], z_covariates: Optional[Set[Column]] = None, + s_node: Optional[Column] = None, ): """Conditional independence test given an oracle. @@ -80,6 +81,9 @@ def test( else: z_covariates_ = set(z_covariates).union(included_nodes) + if s_node is not None: + x_vars.add(s_node) + # just check for d-separation between x and y given sep_set if isinstance(self.graph, nx.DiGraph): is_sep = nx.d_separated(self.graph, x_vars, y_vars, z_covariates_) diff --git a/dodiscover/constraint/__init__.py b/dodiscover/constraint/__init__.py index f21aed4ce..08f692474 100644 --- a/dodiscover/constraint/__init__.py +++ b/dodiscover/constraint/__init__.py @@ -2,4 +2,10 @@ from .fcialg import FCI from .intervention import PsiFCI from .pcalg import PC -from .skeleton import LearnInterventionSkeleton, LearnSemiMarkovianSkeleton, LearnSkeleton +from .sfcialg import SFCI +from .skeleton import ( + LearnInterventionSkeleton, + LearnMultiDomainSkeleton, + LearnSemiMarkovianSkeleton, + LearnSkeleton, +) diff --git a/dodiscover/constraint/_classes.py b/dodiscover/constraint/_classes.py index fafffbab3..ae4f27603 100644 --- a/dodiscover/constraint/_classes.py +++ b/dodiscover/constraint/_classes.py @@ -100,6 +100,7 @@ def __init__( apply_orientations: bool = True, keep_sorted: bool = False, n_jobs: Optional[int] = None, + debug: bool = False, ): self.alpha = alpha self.ci_estimator = ci_estimator @@ -126,6 +127,8 @@ def __init__( # debugging mode self.n_ci_tests = 0 + self.debug = debug + self.debug_map = dict() def _initialize_sep_sets(self, init_graph: nx.Graph) -> SeparatingSet: # keep track of separating sets diff --git a/dodiscover/constraint/fcialg.py b/dodiscover/constraint/fcialg.py index 0c9ace65b..043e477a8 100644 --- a/dodiscover/constraint/fcialg.py +++ b/dodiscover/constraint/fcialg.py @@ -105,6 +105,7 @@ def __init__( selection_bias: bool = True, pds_condsel_method: ConditioningSetSelection = ConditioningSetSelection.PDS, n_jobs: Optional[int] = None, + debug: bool = False, ): super().__init__( ci_estimator, @@ -116,6 +117,7 @@ def __init__( keep_sorted=keep_sorted, apply_orientations=apply_orientations, n_jobs=n_jobs, + debug=debug, ) self.max_iter = max_iter self.max_path_length = max_path_length @@ -152,8 +154,12 @@ def _orient_collider( ) if graph.has_edge(v_i, u, graph.circle_edge_name): graph.orient_uncertain_edge(v_i, u) + if self.debug: + self.debug_map[(v_i, u)] = "collider" if graph.has_edge(v_j, u, graph.circle_edge_name): graph.orient_uncertain_edge(v_j, u) + if self.debug: + self.debug_map[(v_j, u)] = "collider" def _apply_rule1(self, graph: EquivalenceClass, u: Column, a: Column, c: Column) -> bool: """Apply rule 1 of the FCI algorithm. @@ -197,6 +203,10 @@ def _apply_rule1(self, graph: EquivalenceClass, u: Column, a: Column, c: Column) graph.remove_edge(c, u, graph.circle_edge_name) added_arrows = True + if added_arrows and self.debug: + self.debug_map[(u, c)] = f"rule 1: {a} *-> {u} o-* {c}" + self.debug_map[(c, u)] = f"rule 1: {a} *-> {u} o-* {c}" + return added_arrows def _apply_rule2(self, graph: EquivalenceClass, u: Column, a: Column, c: Column) -> bool: @@ -257,6 +267,10 @@ def _apply_rule2(self, graph: EquivalenceClass, u: Column, a: Column, c: Column) # orient a *-> c graph.orient_uncertain_edge(a, c) added_arrows = True + + if added_arrows and self.debug: + self.debug_map[(a, c)] = "rule2" + return added_arrows def _apply_rule3(self, graph: EquivalenceClass, u: Column, a: Column, c: Column) -> bool: @@ -316,6 +330,9 @@ def _apply_rule3(self, graph: EquivalenceClass, u: Column, a: Column, c: Column) logger.info(f"Rule 3: Orienting {v} -> {u}.") graph.orient_uncertain_edge(v, u) added_arrows = True + + if added_arrows and self.debug: + self.debug_map[(v, u)] = "rule3" return added_arrows def _apply_rule4( @@ -404,6 +421,12 @@ def _apply_rule4( logger.info(disc_path_str) added_arrows = True + if added_arrows and self.debug: + if last_node not in sep_set: + self.debug_map[(u, c)] = "rule4" + self.debug_map[(c, u)] = "rule4" + else: + self.debug_map[(u, c)] = "rule4" return added_arrows, explored_nodes def _apply_rule5(self, graph: EquivalenceClass, u: Column, a: Column) -> bool: @@ -451,6 +474,9 @@ def _apply_rule5(self, graph: EquivalenceClass, u: Column, a: Column) -> bool: graph.remove_edge(y, x, graph.circle_edge_name) graph.add_edge(x, y, graph.undirected_edge_name) + if added_tails and self.debug: + self.debug_map[(a, u)] = "rule5" + self.debug_map[(u, a)] = "rule5" return added_tails def _apply_rule6(self, graph: EquivalenceClass, u: Column, a: Column, c: Column) -> bool: @@ -489,6 +515,8 @@ def _apply_rule6(self, graph: EquivalenceClass, u: Column, a: Column, c: Column) ): graph.add_edge(c, u, graph.undirected_edge_name) + if added_tails and self.debug: + self.debug_map[(u, c)] = "rule6" return added_tails def _apply_rule7(self, graph: EquivalenceClass, u: Column, a: Column, c: Column) -> bool: @@ -528,6 +556,8 @@ def _apply_rule7(self, graph: EquivalenceClass, u: Column, a: Column, c: Column) ): graph.add_edge(c, u, graph.undirected_edge_name) + if added_tails and self.debug: + self.debug_map[(u, c)] = "rule7" return added_tails def _apply_rule8(self, graph: EquivalenceClass, u: Column, a: Column, c: Column) -> bool: @@ -589,6 +619,10 @@ def _apply_rule8(self, graph: EquivalenceClass, u: Column, a: Column, c: Column) if graph.has_edge(c, a, graph.circle_edge_name): graph.remove_edge(c, a, graph.circle_edge_name) added_arrows = True + + if added_arrows and self.debug: + self.debug_map[(u, c)] = "rule8" + return added_arrows def _apply_rule9( @@ -640,6 +674,8 @@ def _apply_rule9( graph.remove_edge(c, a, graph.circle_edge_name) added_arrows = True + if added_arrows and self.debug: + self.debug_map[(u, c)] = "rule9" return added_arrows, uncov_path def _apply_rule10( @@ -753,6 +789,9 @@ def _apply_rule10( graph.remove_edge(c, a, graph.circle_edge_name) added_arrows = True + if added_arrows and self.debug: + self.debug_map[(u, c)] = "rule10" + return added_arrows, a_to_u_path, a_to_v_path def _apply_orientation_rules(self, graph: EquivalenceClass, sep_set: SeparatingSet): @@ -820,6 +859,74 @@ def _apply_orientation_rules(self, graph: EquivalenceClass, sep_set: SeparatingS break idx += 1 + def _apply_orientation_rules(self, graph: EquivalenceClass, sep_set: SeparatingSet): + idx = 0 + finished = False + while idx < self.max_iter and not finished: + change_flag = False + logger.info(f"Running R1-10 for iteration {idx}") + + # if self.stable: + # pass + # else: + for u in graph.nodes: + for a, c in permutations(graph.neighbors(u), 2): + logger.debug(f"Check {u} {a} {c}") + + # apply R1-3 to orient triples and arrowheads + r1_add = self._apply_rule1(graph, u, a, c) + r2_add = self._apply_rule2(graph, u, a, c) + r3_add = self._apply_rule3(graph, u, a, c) + + # apply R4, orienting discriminating paths + r4_add, _ = self._apply_rule4(graph, u, a, c, sep_set) + + # apply R5-7 to handle cases where selection bias is present + if self.selection_bias: + r5_add = self._apply_rule5(graph, u, a) + r6_add = self._apply_rule6(graph, u, a, c) + r7_add = self._apply_rule7(graph, u, a, c) + else: + r5_add = False + r6_add = False + r7_add = False + + # apply R8 to orient more tails + r8_add = self._apply_rule8(graph, u, a, c) + + # apply R9-10 to orient uncovered potentially directed paths + r9_add, _ = self._apply_rule9(graph, a, u, c) + + # a and c are neighbors of u, so u is the endpoint desired + r10_add, _, _ = self._apply_rule10(graph, a, c, u) + + # see if there was a change flag + all_flags = [ + r1_add, + r2_add, + r3_add, + r4_add, + r5_add, + r6_add, + r7_add, + r8_add, + r9_add, + r10_add, + ] + if any(all_flags) and not change_flag: + logger.info(f"{change_flag} with " f"{all_flags}") + change_flag = True + + # check if we should continue or not + if not change_flag: + finished = True + if not self.selection_bias: + logger.info(f"Finished applying R1-4, and R8-10 with {idx} iterations") + if self.selection_bias: + logger.info(f"Finished applying R1-10 with {idx} iterations") + break + idx += 1 + def learn_skeleton( self, data: pd.DataFrame, diff --git a/dodiscover/constraint/intervention.py b/dodiscover/constraint/intervention.py index 0ce4409f9..cb41d0364 100644 --- a/dodiscover/constraint/intervention.py +++ b/dodiscover/constraint/intervention.py @@ -1,12 +1,11 @@ import logging from itertools import permutations -from typing import FrozenSet, List, Optional, Tuple +from typing import Callable, FrozenSet, List, Optional, Tuple import networkx as nx import pandas as pd from dodiscover._protocol import EquivalenceClass -from dodiscover.cd import BaseConditionalDiscrepancyTest from dodiscover.ci import BaseConditionalIndependenceTest from dodiscover.context import Context from dodiscover.typing import Column, SeparatingSet @@ -40,7 +39,7 @@ class PsiFCI(FCI): The conditional independence test function. The arguments of the estimator should be data, node, node to compare, conditioning set of nodes, and any additional keyword arguments. - cd_estimator : BaseConditionalDiscrepancyTest + cd_estimator : Callable The conditional discrepancy test function. alpha : float, optional The significance level for the conditional independence test, by default 0.05. @@ -99,7 +98,7 @@ class PsiFCI(FCI): def __init__( self, ci_estimator: BaseConditionalIndependenceTest, - cd_estimator: BaseConditionalDiscrepancyTest, + cd_estimator: Callable, alpha: float = 0.05, min_cond_set_size: Optional[int] = None, max_cond_set_size: Optional[int] = None, @@ -112,6 +111,7 @@ def __init__( pds_condsel_method: ConditioningSetSelection = ConditioningSetSelection.PDS, known_intervention_targets: bool = False, n_jobs: Optional[int] = None, + debug: bool = False, ): super().__init__( ci_estimator, @@ -127,6 +127,7 @@ def __init__( selection_bias=False, pds_condsel_method=pds_condsel_method, n_jobs=n_jobs, + debug=debug, ) self.cd_estimator = cd_estimator self.known_intervention_targets = known_intervention_targets @@ -240,7 +241,7 @@ def _apply_rule11(self, graph: EquivalenceClass, context: Context) -> Tuple[bool augmented_nodes = context.get_augmented_nodes() oriented_edges = [] - added_arrows = True + added_arrows = False for node in augmented_nodes: for nbr in graph.neighbors(node): if nbr in augmented_nodes: @@ -251,6 +252,12 @@ def _apply_rule11(self, graph: EquivalenceClass, context: Context) -> Tuple[bool graph.remove_edge(nbr, node) graph.add_edge(node, nbr, graph.directed_edge_name) oriented_edges.append((node, nbr)) + + added_arrows = True + + if added_arrows and self.debug: + self.debug_map[(node, nbr)] = "Rule 11" + return added_arrows, oriented_edges def _apply_rule12( @@ -311,6 +318,9 @@ def _apply_rule12( graph.add_edge(a, c, graph.directed_edge_name) added_arrows = True + + if added_arrows and self.debug: + self.debug_map[(a, c)] = "Rule 12" return added_arrows def _apply_orientation_rules(self, graph: EquivalenceClass, sep_set: SeparatingSet): diff --git a/dodiscover/constraint/pcalg.py b/dodiscover/constraint/pcalg.py index feff1e06b..8f62f864f 100644 --- a/dodiscover/constraint/pcalg.py +++ b/dodiscover/constraint/pcalg.py @@ -100,6 +100,7 @@ def __init__( keep_sorted: bool = False, max_iter: int = 1000, n_jobs: Optional[int] = None, + debug: bool = False, ): super().__init__( ci_estimator, @@ -111,6 +112,7 @@ def __init__( apply_orientations=apply_orientations, keep_sorted=keep_sorted, n_jobs=n_jobs, + debug=debug, ) self.max_iter = max_iter @@ -274,10 +276,16 @@ def _orient_collider( f"orienting collider: {v_i} -> {u} and {v_j} -> {u} to make {v_i} -> {u} <- {v_j}." ) + # XXX: this should be a base method that is common to all constraint-based causal discovery + # We can integrate this with FCI probably if graph.has_edge(v_i, u, graph.undirected_edge_name): graph.orient_uncertain_edge(v_i, u) + if self.debug: + self.debug_map[(v_i, u)] = "collider" if graph.has_edge(v_j, u, graph.undirected_edge_name): graph.orient_uncertain_edge(v_j, u) + if self.debug: + self.debug_map[(v_j, u)] = "collider" def _apply_meek_rule1(self, graph: EquivalenceClass, i: Column, j: Column) -> bool: """Apply rule 1 of Meek's rules. @@ -305,6 +313,9 @@ def _apply_meek_rule1(self, graph: EquivalenceClass, i: Column, j: Column) -> bo added_arrows = True break + + if added_arrows and self.debug: + self.debug_map[(i, j)] = "rule1" return added_arrows def _apply_meek_rule2(self, graph: EquivalenceClass, i: Column, j: Column) -> bool: @@ -344,6 +355,9 @@ def _apply_meek_rule2(self, graph: EquivalenceClass, i: Column, j: Column) -> bo logger.info(f"R2: Removing edge {i}-{j} to form {i}->{j}.") graph.orient_uncertain_edge(i, j) added_arrows = True + + if added_arrows and self.debug: + self.debug_map[(i, j)] = "rule2" return added_arrows def _apply_meek_rule3(self, graph: EquivalenceClass, i: Column, j: Column) -> bool: @@ -386,4 +400,7 @@ def _apply_meek_rule3(self, graph: EquivalenceClass, i: Column, j: Column) -> bo graph.orient_uncertain_edge(i, j) added_arrows = True break + + if added_arrows and self.debug: + self.debug_map[(i, j)] = "rule3" return added_arrows diff --git a/dodiscover/constraint/sfcialg.py b/dodiscover/constraint/sfcialg.py new file mode 100644 index 000000000..20d0cfc92 --- /dev/null +++ b/dodiscover/constraint/sfcialg.py @@ -0,0 +1,223 @@ +from typing import Callable, FrozenSet, List, Optional, Tuple + +import networkx as nx +import pandas as pd + +from dodiscover._protocol import EquivalenceClass +from dodiscover.ci import BaseConditionalIndependenceTest +from dodiscover.constraint.config import ConditioningSetSelection +from dodiscover.typing import Column, SeparatingSet + +from ..context import Context +from .intervention import PsiFCI +from .skeleton import LearnMultiDomainSkeleton + + +class SFCI(PsiFCI): + def __init__( + self, + ci_estimator: BaseConditionalIndependenceTest, + cd_estimator: Callable, + alpha: float = 0.05, + min_cond_set_size: Optional[int] = None, + max_cond_set_size: Optional[int] = None, + max_combinations: Optional[int] = None, + condsel_method: ConditioningSetSelection = ConditioningSetSelection.NBRS, + apply_orientations: bool = True, + keep_sorted: bool = False, + max_iter: int = 1000, + max_path_length: Optional[int] = None, + pds_condsel_method: ConditioningSetSelection = ConditioningSetSelection.PDS, + n_jobs: Optional[int] = None, + debug: bool = False, + ): + super().__init__( + ci_estimator, + cd_estimator, + alpha, + min_cond_set_size, + max_cond_set_size, + max_combinations, + condsel_method, + apply_orientations, + keep_sorted, + max_iter, + max_path_length, + pds_condsel_method, + n_jobs=n_jobs, + debug=debug, + ) + + def learn_skeleton( + self, + data: pd.DataFrame, + context: Context, + sep_set: Optional[SeparatingSet] = None, + **params, + ) -> Tuple[nx.Graph, SeparatingSet]: + # now compute all possibly d-separating sets and learn a better skeleton + self.skeleton_learner_ = LearnMultiDomainSkeleton( + self.ci_estimator, + self.cd_estimator, + sep_set=sep_set, + alpha=self.alpha, + min_cond_set_size=self.min_cond_set_size, + max_cond_set_size=self.max_cond_set_size, + max_combinations=self.max_combinations, + condsel_method=self.condsel_method, + second_stage_condsel_method=self.pds_condsel_method, + keep_sorted=False, + max_path_length=self.max_path_length, + n_jobs=self.n_jobs, + ) + self.skeleton_learner_.learn_graph( + data, context, self.domain_indices, self.intervention_targets, debug=self.debug + ) + + self.context_ = self.skeleton_learner_.context_.copy() + skel_graph = self.skeleton_learner_.adj_graph_ + sep_set = self.skeleton_learner_.sep_set_ + self.n_ci_tests += self.skeleton_learner_.n_ci_tests + return skel_graph, sep_set + + def learn_graph( + self, data: List[pd.DataFrame], context: Context, domain_indices, intervention_targets + ): + """Learn the relevant causal graph equivalence class. + + From the pairs of datasets, we take all combinations and + construct F-nodes corresponding to those. + + Parameters + ---------- + data : List[pd.DataFrame] + The list of different datasets assigned to different + environments. We assume the first dataset is always + observational. + context : Context + The context with interventional assumptions. + + Returns + ------- + self : PsiFCI + The fitted learner. + """ + if not isinstance(data, list): + raise RuntimeError("The input datasets must be in a Python list.") + + # n_datasets = len(data) + # n_distributions = context.num_distributions + + # if n_datasets != n_distributions: + # raise RuntimeError( + # f"There are {n_datasets} passed in, but {n_distributions} " + # f"total assumed distributions. There must be a matching number of datasets and " + # f"'context.num_distributions'." + # ) + self.domain_indices = domain_indices + self.intervention_targets = intervention_targets + + return super().learn_graph(data, context) + + def _apply_rule12( + self, + graph: EquivalenceClass, + u: Column, + a: Column, + c: Column, + context: Context, + ) -> bool: + """Apply "Rule 9" of the I-FCI algorithm. + + Checks for inducing paths where 'u' is the F-node, and 'a' and 'c' are connected: + + 'u' -> 'a' *-* 'c' with 'u' -> 'c', then orient 'a' -> 'c'. + + For original details of the rule, see :footcite:`Kocaoglu2019characterization`. + + Parameters + ---------- + graph : EquivalenceClass + The causal graph. + u : Column + The candidate F-node + a : Column + Neighbors of the F-node. + c : Column + Neighbors of the F-node. + symmetric_diff_map : dict + A mapping from the F-nodes to the symmetric difference of the pair of + intervention targets each F-node represents. I.e. if F-node, F1 represents + the pair of intervention distributions with targets {'x'}, and {'x', 'y'}, + then F1 maps to {'y'} in the symmetric diff map. + + Returns + ------- + added_arrows : bool + Whether or not an orientation was made. + + References + ---------- + .. footbibliography:: + """ + f_nodes = context.f_nodes + symmetric_diff_map = context.symmetric_diff_map + + added_arrows = False + if u in f_nodes and self.known_intervention_targets: + # get sigma map to map F-node to its symmetric difference target + S_set: FrozenSet = symmetric_diff_map.get(u, frozenset()) + + # check domain + domains_u = context.domain_map[u] + + # check the presence of an S-node for that domain + if len(domains_u) == 2: + for s_node in context.s_nodes: + if context.domain_map[s_node] == domains_u: + # check if the s-node is d-connected to F + if graph.has_edge(s_node, c): + return False + + # now, we know that there is no S-node for the domain of u + # that will alter the distribution of a/c, so we check for + # an inducing path that we can orient properly + # check a *-* c + if ( + len(S_set) == 1 + and a in S_set + and (graph.has_edge(a, c) or graph.has_edge(c, a)) + and graph.has_edge(u, a) + and graph.has_edge(u, c) + ): + # remove all edges between a and c + graph.remove_edge(a, c) + graph.remove_edge(c, a) + + # then orient X -> Y + graph.add_edge(a, c, graph.directed_edge_name) + + added_arrows = True + + if added_arrows and self.debug: + self.debug_map[(a, c)] = "rule 12" + return added_arrows + + def convert_skeleton_graph(self, graph: nx.Graph) -> EquivalenceClass: + import pywhy_graphs as pgraph + + # convert the undirected skeleton graph to its PAG-class, where + # all left-over edges have a "circle" endpoint + pag = pgraph.AugmentedPAG(incoming_circle_edges=graph, name="SPAG derived with S-FCI") + + # get the graph attributes + pag.graph = graph.graph + + # XXX: assign targets as well + # assign f-nodes + # for f_node in self.context_.f_nodes: + # pag.set_f_node(f_node) + # for s_node in self.context_.s_nodes: + # domain_ids = self.context_.domain_map[s_node] + # pag.add_s_node(s_node, domain_ids=domain_ids, node_changes=) + return pag diff --git a/dodiscover/constraint/skeleton.py b/dodiscover/constraint/skeleton.py index 72166b795..aa189d75b 100644 --- a/dodiscover/constraint/skeleton.py +++ b/dodiscover/constraint/skeleton.py @@ -7,10 +7,10 @@ import networkx as nx import numpy as np import pandas as pd +import pywhy_graphs as pg from joblib import Parallel, delayed -from dodiscover.cd import BaseConditionalDiscrepancyTest -from dodiscover.ci import BaseConditionalIndependenceTest +from dodiscover.ci import BaseConditionalIndependenceTest, Oracle from dodiscover.constraint.config import ConditioningSetSelection from dodiscover.constraint.utils import is_in_sep_set from dodiscover.typing import Column, SeparatingSet @@ -62,6 +62,12 @@ def _test_xy_edges( A set of variables that are candidates for the conditioning set. data : pandas.Dataframe The dataset with variables as columns and samples as rows. + context : Context + The causal context. + cross_distribution_test : bool, optional + Whether to perform cross-distribution tests. If True, then the ``context`` + object must contain a ``sigma_map`` attribute that maps each X-node + to the corresponding distribution indices of interest. Returns ------- @@ -94,6 +100,8 @@ def _test_xy_edges( # get the sigma-map for this F-node distribution_idx = context.sigma_map[x_var] + # print(f'Got distribution indices for {x_var} as {distribution_idx}') + # get the distributions across the two distributions data_i = data[distribution_idx[0]].copy() data_j = data[distribution_idx[1]].copy() @@ -105,6 +113,7 @@ def _test_xy_edges( try: # compute conditional independence test + # print(s_node, x_var, y_var, conditional_test_func, parallel_fun) test_stat, pvalue = parallel_fun( this_data, conditional_test_func, x_var, y_var, set(cond_set) ) @@ -114,6 +123,15 @@ def _test_xy_edges( test_stat = np.inf pvalue = 0.0 else: + import traceback + + print("\n\ninside error message...") + print(x_var, y_var, cond_set) + print(this_data.columns) + print(this_data.head()) + print(this_data[x_var]) + print(context.init_graph.nodes) + traceback.print_exc() raise Exception(e) # if any "independence" is found through inability to reject @@ -643,6 +661,7 @@ def evaluate_edge( X: Column, Y: Column, Z: Optional[Set[Column]] = None, + **kwargs, ) -> Tuple[float, float]: """Test any specific edge for X || Y | Z. @@ -656,6 +675,9 @@ def evaluate_edge( A column in ``data``. Z : set, optional A list of columns in ``data``, by default None. + **kwargs + Keyword arguments to be passed to the conditional independence test. + Allows S-nodes for example to be passed in. Returns ------- @@ -666,7 +688,9 @@ def evaluate_edge( """ if Z is None: Z = set() - test_stat, pvalue = conditional_test_func.test(data, set({X}), set({Y}), Z) + if not isinstance(conditional_test_func, Oracle): + kwargs = dict() + test_stat, pvalue = conditional_test_func.test(data, set({X}), set({Y}), Z, **kwargs) self.n_ci_tests += 1 return test_stat, pvalue @@ -1085,19 +1109,15 @@ def _initialize_params(self, context) -> Context: return super()._initialize_params(context) - def learn_graph( - self, data: pd.DataFrame, context: Optional[Context] = None, check_input: bool = True + def _fit_single_distribution( + self, + data, + context: Context, + possible_x_nodes, + skipped_y_nodes, + skipped_z_nodes, + cross_distribution_test, ): - if context is None: - # make a private Context object to store causal context used in this algorithm - # store the context - from dodiscover.context_builder import make_context - - context = make_context().build() - - if check_input: - context = self._initialize_params(context) - # initially learn the skeleton without using PDS information # apply algorithm to learn skeleton self._learn_skeleton( @@ -1105,17 +1125,21 @@ def learn_graph( context=context, condsel_method=self.condsel_method, conditional_test_func=self.ci_estimator, + possible_x_nodes=possible_x_nodes, + skipped_y_nodes=skipped_y_nodes, + skipped_z_nodes=skipped_z_nodes, + cross_distribution_test=cross_distribution_test, ) + # reset context and add observational skeleton + context.add_state_variable("obs_skel_graph", context.init_graph.copy()) + # if there is no second stage skeleton method to be run, then we # will stop with the skeleton here - print(self.second_stage_condsel_method) - print(context) if self.second_stage_condsel_method is None: self.context_ = deepcopy(context.copy()) self.adj_graph_ = deepcopy(context.init_graph.copy()) - print("Shuldnt run second stage...") - return self + return context # setup context for the second round-of learning context = self._prep_second_stage_skeleton(context) @@ -1127,6 +1151,34 @@ def learn_graph( context=context, condsel_method=self.second_stage_condsel_method, conditional_test_func=self.ci_estimator, + possible_x_nodes=possible_x_nodes, + skipped_y_nodes=skipped_y_nodes, + skipped_z_nodes=skipped_z_nodes, + cross_distribution_test=cross_distribution_test, + ) + return context + + def learn_graph( + self, data: pd.DataFrame, context: Optional[Context] = None, check_input: bool = True + ): + if context is None: + # make a private Context object to store causal context used in this algorithm + # store the context + from dodiscover.context_builder import make_context + + context = make_context().build() + + if check_input: + context = self._initialize_params(context) + + # fit the distribution + context = self._fit_single_distribution( + data, + context, + possible_x_nodes=None, + skipped_y_nodes=None, + skipped_z_nodes=None, + cross_distribution_test=False, ) self.context_ = deepcopy(context.copy()) @@ -1146,7 +1198,7 @@ class LearnInterventionSkeleton(LearnSemiMarkovianSkeleton): ---------- ci_estimator : BaseConditionalIndependenceTest The conditional independence test function. - cd_estimator : BaseConditionalDiscrepancyTest + cd_estimator : Callable The conditional discrepancy test function. sep_set : dictionary of dictionary of list of set Mapping node to other nodes to separating sets of variables. @@ -1201,7 +1253,7 @@ class LearnInterventionSkeleton(LearnSemiMarkovianSkeleton): def __init__( self, ci_estimator: BaseConditionalIndependenceTest, - cd_estimator: BaseConditionalDiscrepancyTest, + cd_estimator: Callable, sep_set: Optional[SeparatingSet] = None, alpha: float = 0.05, min_cond_set_size: int = 0, @@ -1231,6 +1283,23 @@ def __init__( self.cd_estimator = cd_estimator self.known_intervention_targets = known_intervention_targets + def _prep_second_stage_skeleton(self, context: Context) -> Context: + # prepare the context object for the second stage of learning + # all separating sets are either: + # i) augmented with all F-nodes, or + # ii) augmented with all F-nodes except intervention index 'i' + # R9 allows us to leverage F-nodes being not in separating sets to + # augment all separating sets that have non-empty sets with all + # F-nodes to keep consistency with the algorithm + # for x_var, y_vars in self.sep_set_.items(): + # for y_var in y_vars: + # sep_sets: List = self.sep_set_.get(x_var).get(y_var) # type: ignore + # if len(sep_sets) > 0: + # for idx in range(len(sep_sets)): + # self.sep_set_[x_var][y_var][idx].update(context.get_augmented_nodes()) + + return super()._prep_second_stage_skeleton(context) + def learn_graph( self, data: List[pd.DataFrame], context: Optional[Context] = None, check_input: bool = True ) -> None: @@ -1273,20 +1342,29 @@ def learn_graph( self.context_ = context.copy() - # first learn the skeleton using only "observational data" - self._learn_skeleton( + # learn skeleton + context = self._fit_single_distribution( data=obs_data, context=context, - condsel_method=self.condsel_method, - conditional_test_func=self.ci_estimator, possible_x_nodes=list(context.get_non_augmented_nodes()), skipped_y_nodes=context.f_nodes, skipped_z_nodes=context.f_nodes, cross_distribution_test=False, ) - # keep track of the observational skeleton graph - obs_skel_graph = self.adj_graph_.copy() + context = self._prep_second_stage_skeleton(context) + + # secibd learn the skeleton using only "PDS data" + self._learn_skeleton( + data=obs_data, + context=context, + condsel_method=self.second_stage_condsel_method, + conditional_test_func=self.ci_estimator, + possible_x_nodes=list(context.get_non_augmented_nodes()), + skipped_y_nodes=context.get_augmented_nodes(), + skipped_z_nodes=context.get_augmented_nodes(), + cross_distribution_test=False, + ) # prepare the context object for the second stage of learning # all separating sets are either: @@ -1300,47 +1378,15 @@ def learn_graph( sep_sets: List = self.sep_set_.get(x_var).get(y_var) # type: ignore if len(sep_sets) > 0: for idx in range(len(sep_sets)): - self.sep_set_[x_var][y_var][idx].update(f_nodes) - - # index all datasets, where the first one may be observational - non_f_nodes = context.get_non_augmented_nodes() + self.sep_set_[x_var][y_var][idx].update(context.get_augmented_nodes()) - # reset the init graph and this time learn the skeleton using - # interventional distributions - # create a complete subgraph of F-nodes with all other nodes - for node in f_nodes: - for obs_node in set(non_f_nodes): - if node == obs_node: + # remove all edges between F-nodes + for x_var in context.get_augmented_nodes(): + for y_var in context.get_augmented_nodes(): + if x_var == y_var: continue - self.adj_graph_.add_edge(node, obs_node, test_stat=np.inf, pvalue=-1e-5) - - # reset context and add observational skeleton - context.add_state_variable("obs_skel_graph", obs_skel_graph) - - # convert the undirected skeleton graph to a PAG, where - # all left-over edges have a "circle" endpoint - sep_set = self.sep_set_ - import pywhy_graphs - - pag = pywhy_graphs.PAG(incoming_circle_edges=obs_skel_graph, name="PAG derived with FCI") - - # orient colliders - self._orient_unshielded_triples(pag, sep_set) - - context.add_state_variable("PAG", pag) - context.add_state_variable("max_path_length", self.max_path_length_) - - # secibd learn the skeleton using only "PDS data" - self._learn_skeleton( - data=obs_data, - context=context, - condsel_method=self.second_stage_condsel_method, - conditional_test_func=self.ci_estimator, - possible_x_nodes=list(context.get_non_augmented_nodes()), - skipped_y_nodes=context.f_nodes, - skipped_z_nodes=context.f_nodes, - cross_distribution_test=False, - ) + if context.init_graph.has_edge(x_var, y_var): + context.init_graph.remove_edge(x_var, y_var) # now, we'll fit the data using interventional data by looping over all # combinations of F-nodes and their neighbors @@ -1351,8 +1397,8 @@ def learn_graph( condsel_method=self.second_stage_condsel_method, conditional_test_func=self.cd_estimator, possible_x_nodes=list(self.context_.f_nodes), - skipped_y_nodes=context.f_nodes, - skipped_z_nodes=context.f_nodes, + skipped_y_nodes=context.get_augmented_nodes(), + skipped_z_nodes=context.get_augmented_nodes(), cross_distribution_test=True, ) @@ -1377,3 +1423,387 @@ def learn_graph( self.context_ = context.copy() self.adj_graph_ = deepcopy(context.init_graph.copy()) + + +class LearnMultiDomainSkeleton(LearnInterventionSkeleton): + """Learn skeleton of a augmented selection diagram. + + Parameters + ---------- + ci_estimator : BaseConditionalIndependenceTest + The conditional independence test function. + cd_estimator : Callable + The conditional discrepancy test function. + sep_set : dictionary of dictionary of list of set + Mapping node to other nodes to separating sets of variables. + If ``None``, then an empty dictionary of dictionary of list of sets + will be initialized. + alpha : float, optional + The significance level for the conditional independence test, by default 0.05. + min_cond_set_size : int + The minimum size of the conditioning set, by default 0. The number of variables + used in the conditioning set. + max_cond_set_size : int, optional + Maximum size of the conditioning set, by default None. Used to limit + the computation spent on the algorithm. + max_combinations : int, optional + The maximum number of conditional independence tests to run from the set + of possible conditioning sets. By default None, which means the algorithm will + check all possible conditioning sets. If ``max_combinations=n`` is set, then + for every conditioning set size, 'p', there will be at most 'n' CI tests run + before the conditioning set size 'p' is incremented. For controlling the size + of 'p', see ``min_cond_set_size`` and ``max_cond_set_size``. This can be used + in conjunction with ``keep_sorted`` parameter to only test the "strongest" + dependences. + condsel_method : ConditioningSetSelection + The method to use for testing conditional independence. Must be one of + ('pds', 'pds_path'). See Notes for more details. + keep_sorted : bool + Whether or not to keep the considered conditioning set variables in sorted + dependency order. If True (default) will sort the existing dependencies of each variable + by its dependencies from strongest to weakest (i.e. largest CI test statistic value + to lowest). This can be used in conjunction with ``max_combinations`` parameter + to only test the "strongest" dependences. + max_path_length : int, optional + The maximum length of any discriminating path, or None if unlimited. + n_jobs : int, optional + Number of CPUs to use, by default None. + + Notes + ----- + With interventional data, one may either know the interventional targets from each + experimental distribution dataset, or one may not know the explicit targets. If the + interventional targets are known, then the skeleton discovery algorithm of + :footcite:`Kocaoglu2019characterization` is used. That is we learn the skeleton of a + AugmentedPAG. Otherwise, we will not know the intervention targets, and use the skeleton + discovery algorithm described in :footcite:`Jaber2020causal`. To define intervention + targets, one must use the :class:`dodiscover.InterventionalContextBuilder`. + + References + ---------- + .. footbibliography:: + """ + + def __init__( + self, + ci_estimator: BaseConditionalIndependenceTest, + cd_estimator: Callable, + sep_set: Optional[SeparatingSet] = None, + alpha: float = 0.05, + min_cond_set_size: int = 0, + max_cond_set_size: Optional[int] = None, + max_combinations: Optional[int] = None, + condsel_method: ConditioningSetSelection = ConditioningSetSelection.NBRS, + second_stage_condsel_method: ConditioningSetSelection = ConditioningSetSelection.PDS, + keep_sorted: bool = False, + max_path_length: Optional[int] = None, + known_intervention_targets: bool = False, + n_jobs: Optional[int] = None, + ) -> None: + super().__init__( + ci_estimator=ci_estimator, + cd_estimator=cd_estimator, + sep_set=sep_set, + alpha=alpha, + min_cond_set_size=min_cond_set_size, + max_cond_set_size=max_cond_set_size, + max_combinations=max_combinations, + condsel_method=condsel_method, + second_stage_condsel_method=second_stage_condsel_method, + keep_sorted=keep_sorted, + max_path_length=max_path_length, + n_jobs=n_jobs, + ) + + self.known_intervention_targets = known_intervention_targets + + # def _create_augmented_nodes( + # self, domain_ids: List[int], intervention_targets: List[Optional[Set]] + # ) -> Tuple[List, Dict, Dict, Dict]: + # """Create augmented nodes, sigma map and optionally a symmetric difference map. + + # Given a number of distributions attributed to interventions, one constructs + # F-nodes to add to the causal graph by: + + # - For all pairs of incoming distributions, form a new F-node for every + # pair of distributions. Update ``node_domain_map`` to map the F-node to + # a specific domain. + # - If the pairs are from two known target-interventions (i.e. not `None` + # value), then also add the symmetric difference mapping to + # ``symmetric_diff_map``, which maps the F-node to the intervention target. + + # where ``targets`` is a set of either nodes, or set of indices corresponding + # to the input data distributions and ``domains`` is a set of domains, either + # a single domain for F-nodes within domain, or a set of two domains for + # F-nodes across domains. + + # Parameters + # ---------- + # domain_ids : List[int] + # A list of domain ids for each input distribution. + # intervention_targets : List[Set] + # A list of known intervention targets for each input distribution with ``None`` + # representing unknown targets. If the distribution is observational, then + # the empty set is used. + + # Returns + # ------- + # augmented_nodes : List + # Set of augmented nodes (i.e. F and S nodes). + # symmetric_diff_map : Dict[Any, FrozenSet] + # Mapping of augmented nodes to intervention targets, or distribution indices represented + # by the node. + # sigma_map : Dict[Any, FrozenSet] + # Mapping of augmented nodes to distribution indices represented by the node. + # node_domain_map : Dict[Any, FrozenSet] + # Mapping of augmented nodes to domains. + # """ + # # map augmented nodes to domains + # node_domain_map = dict() + # symmetric_diff_map = dict() + # sigma_map = dict() + # f_nodes = [] + + # # create F-nodes, which is now all combinations of distributions choose 2 + # k = 0 + # seen_domain_pairs = dict() + # seen_distr_pairs = dict() + + # # compare every pair of distributions to now add interventions if necessary + # for dataset_idx, source in enumerate(domain_ids): + # for dataset_jdx, target in enumerate(domain_ids): + # # perform memoization to avoid duplicate augmented nodes + # domain_memo_key = frozenset([source, target]) + # distr_memo_key = frozenset([dataset_idx, dataset_jdx]) + # if dataset_jdx <= dataset_idx: + # continue + # if domain_memo_key in seen_domain_pairs and distr_memo_key in seen_distr_pairs: + # continue + # seen_domain_pairs[domain_memo_key] = None + # seen_distr_pairs[distr_memo_key] = None + + # # map each augmented-node to a tuple of distribution indices, or to a set of nodes + # # representing the intervention targets + # if ( + # intervention_targets[dataset_idx] is not None + # and intervention_targets[dataset_jdx] is not None + # and source == target + # ): + # symm_diff = set(intervention_targets[dataset_idx]).symmetric_difference( + # set(intervention_targets[dataset_jdx]) + # ) + # targets = frozenset(symm_diff) + # else: + # targets = None + + # # create the F-node + # f_node = ("F", k) + # f_nodes.append(f_node) + + # # map each F-node to a set of domain(s) + # node_domain_map[f_node] = [source, target] + # sigma_map[f_node] = [dataset_idx, dataset_jdx] + # symmetric_diff_map[f_node] = targets + + # k += 1 + # augmented_nodes = set(f_nodes) + # return augmented_nodes, symmetric_diff_map, sigma_map, node_domain_map + + def learn_graph( + self, + data: List[pd.DataFrame], + context: Optional[Context] = None, + domain_indices: Optional[List[int]] = None, + intervention_targets: Optional[List[Optional[Set]]] = None, + check_input: bool = True, + debug: bool = False, + **params, + ) -> None: + """Fit data and context. + + Each dataframe in ``data`` corresponds to a different distribution of data + from a domain and specific intervention target. + + Parameters + ---------- + data : List[pd.DataFrame] + List of dataframes corresponding to different distributions of data. + context : Context + Context object. + domain_indices : List[int] + The domain indices of each dataframe in ``data``. + intervention_targets : List[Column] + The intervention targets of each dataframe in ``data``. Is ``None`` if + ``data`` is observational, or ``unknown`` if target is unknown. + """ + if context is None: + # make a private Context object to store causal context used in this algorithm + # store the context + from dodiscover.context_builder import make_context + + context = make_context().build() + + # ensure data is a list + if isinstance(data, pd.DataFrame): + data = [data] + + # pick a domain and distribution with the largest amount of data + largest_data_idx = np.argmax([len(df) for df in data]) + obs_data = data[largest_data_idx] + print("Using data from distribution ", largest_data_idx, " for learning the skeleton.") + self.context_ = context.copy() + + # initialize learning parameters + if check_input: + context = self._initialize_params(context) + + # create augmented nodes + ( + augmented_nodes, + symmetric_diff_map, + sigma_map, + node_domain_map, + ) = pg.classes.compute_augmented_nodes( + intervention_targets=intervention_targets, domain_ids=domain_indices + ) + + # initialize the augmented graph to be fully connected to observed casual variables + causal_nodes = set(context.observed_variables) + + # XXX: contextbuilder creates an augmented graph, whereas we want to control that. + for node in set(context.init_graph.nodes): + if node not in causal_nodes: + context.init_graph.remove_node(node) + for augmented_node in augmented_nodes: + for node in causal_nodes: + context.init_graph.add_edge(augmented_node, node) + + # extract F and S-nodes + f_nodes = augmented_nodes + + # skeleton discovery should not condition on augmented nodes + skip_nodes = augmented_nodes + + # provide multi-domain context + # XXX: Do I need to do assignment? + # context.augmented_nodes = augmented_nodes + # context.node_domain_map = node_domain_map + context.add_state_variable("node_domain_map", node_domain_map) + context.add_state_variable("augmented_nodes", augmented_nodes) + context.symmetric_diff_map = symmetric_diff_map + context.sigma_map = sigma_map + context.f_nodes = f_nodes + + # first learn the skeleton using only "observational data" + # initially learn the skeleton without using PDS information + # apply algorithm to learn skeleton + # first learn the skeleton using only "observational data" + self._fit_single_distribution( + data=obs_data, + context=context, + possible_x_nodes=causal_nodes, + skipped_y_nodes=skip_nodes, + skipped_z_nodes=skip_nodes, + cross_distribution_test=False, + ) + + # prepare the context object for the second stage of learning + # all separating sets are either: + # i) augmented with all F-nodes, or + # ii) augmented with all F-nodes except intervention index 'i' + # R9 allows us to leverage F-nodes being not in separating sets to + # augment all separating sets that have non-empty sets with all + # F-nodes to keep consistency with the algorithm + # for x_var, y_vars in self.sep_set_.items(): + # for y_var in y_vars: + # sep_sets: List = self.sep_set_.get(x_var).get(y_var) # type: ignore + # if len(sep_sets) > 0: + # for idx in range(len(sep_sets)): + # self.sep_set_[x_var][y_var][idx].update(context.f_nodes) + + # loop through each pair of datasets to learn the augmented F-node skeleton + seen_domain_pairs = set() + for idx, source in enumerate(domain_indices): + # analyze F-nodes only within the 'source' single domain + source_fnodes = [ + node for node in augmented_nodes if set(node_domain_map[node]) == {source} + ] + if debug: + print(f"Trying to learn skeleton for {source} to remove F-nodes: {source_fnodes}") + if source_fnodes: + # apply algorithm to learn skeleton among the F-node subgraph within a single domain + self._learn_skeleton( + data=data, + context=context, + condsel_method=self.second_stage_condsel_method, + conditional_test_func=self.cd_estimator, + possible_x_nodes=source_fnodes, + skipped_y_nodes=skip_nodes, + skipped_z_nodes=skip_nodes, + cross_distribution_test=True, + ) + + # now compute skeleton among all possible F-nodes representing domain pairs + for idx, source in enumerate(domain_indices): + for jdx, target in enumerate(domain_indices): + # skip the same dataset + if idx == jdx: + continue + + # skip if source and target are the same domain because we have + # already learned from these pairs of datasets + if source == target: + continue + # skip if we have already seen this domain pair + if frozenset([source, target]) in seen_domain_pairs: + continue + seen_domain_pairs.add(frozenset([source, target])) + + # now learn across interventions + this_f_nodes = [ + node + for node in f_nodes + if set(node_domain_map[node]) == {source, target} and node in symmetric_diff_map + ] + if debug: + print( + f"Trying to learn skeleton for {source} and {target} to remove F-nodes: " + f"{this_f_nodes}" + ) + self._learn_skeleton( + data=data, + context=context, + condsel_method=self.second_stage_condsel_method, + conditional_test_func=self.cd_estimator, + possible_x_nodes=this_f_nodes, + skipped_y_nodes=skip_nodes, + skipped_z_nodes=skip_nodes, + cross_distribution_test=True, + # debug=debug, + ) + + # prepare the context object for the second stage of learning + # all separating sets are either: + # i) augmented with all F-nodes, or + # ii) augmented with all F-nodes except intervention index 'i' + # R9 allows us to leverage F-nodes being not in separating sets to + # augment all separating sets that have non-empty sets with all + # F-nodes to keep consistency with the algorithm + for x_var, y_vars in self.sep_set_.items(): + for y_var in y_vars: + sep_sets: List = self.sep_set_.get(x_var).get(y_var) # type: ignore + if len(sep_sets) > 0: + for idx in range(len(sep_sets)): + if x_var in augmented_nodes: + self.sep_set_[x_var][y_var][idx].update( + augmented_nodes.difference({x_var}) + ) + elif y_var in augmented_nodes: + self.sep_set_[x_var][y_var][idx].update( + augmented_nodes.difference({y_var}) + ) + else: + self.sep_set_[x_var][y_var][idx].update(augmented_nodes) + + self.context_ = context.copy() + self.adj_graph_ = deepcopy(context.init_graph.copy()) diff --git a/dodiscover/context.py b/dodiscover/context.py index 4a5187740..f491b41c4 100644 --- a/dodiscover/context.py +++ b/dodiscover/context.py @@ -45,7 +45,11 @@ class Context(BasePyWhy): intervention_targets : list of tuple List of intervention targets (known, or unknown), which correspond to the nodes in the graph (known), or indices of datasets that contain - interventions (unknown). + interventions (unknown). If the value of an element is `None`, then + it means the distribution corresponding to the element's index has + an unknown intervention target. If the value is an empty set, then it + implies this is an observational distribution. If the value is a non-empty + set, then it informs us of the intervention targets. Raises ------ diff --git a/examples/plot_pc_alg.py b/examples/constraint/plot_pc_alg.py similarity index 100% rename from examples/plot_pc_alg.py rename to examples/constraint/plot_pc_alg.py diff --git a/examples/plot_psifci_alg.py b/examples/constraint/plot_psifci_alg.py similarity index 100% rename from examples/plot_psifci_alg.py rename to examples/constraint/plot_psifci_alg.py diff --git a/examples/constraint/plot_sfci_alg.py b/examples/constraint/plot_sfci_alg.py new file mode 100644 index 000000000..bbc4b85e8 --- /dev/null +++ b/examples/constraint/plot_sfci_alg.py @@ -0,0 +1,149 @@ +""" +.. _ex-psifci-algorithm: + +========================================================= +Causal discovery with interventional data - Sachs dataset +========================================================= + +We will analyze the Sachs dataset :footcite:`sachsdataset2005` and reproduce analyses +from the Supplemental Figure 8 in :footcite:`Jaber2020causal` demonstrating the +usage of the :class:`dodiscover.constraint.PsiFCI` algorithm for learning causal graphs +from observational and interventional data. + +.. currentmodule:: dodiscover +""" + +# %% +# Authors: Adam Li +# +# License: BSD (3-clause) + +from pywhy_graphs.viz import draw +from dodiscover.ci import GSquareCITest +from dodiscover import SFCI, Context, make_context, InterventionalContextBuilder + +import numpy as np +import pandas as pd +import bnlearn + +import pooch + +# %% +# Pull in the Sachs Dataset +# ------------------------- +# The Sachs dataset is a famous dataset in causal discovery because of its real-life +# applicability and access to experimental data that analyzed the causal network of +# 11 proteins using knockouts and spikings :footcite:`sachsdataset2005`. The pathways +# for those proteins are already known, so it is an ideal dataset for benchmarking +# causal discovery algorithms. +# +# We will download a preprocessed version of the dataset from the following +# url: https://www.bnlearn.com/book-crc/code/sachs.interventional.txt.gz +# +# Ref: https://erdogant.github.io/bnlearn/pages/html/bnlearn.bnlearn.html#bnlearn.bnlearn.import_example # noqa + +# use pooch to download robustly from a url +url = "https://www.bnlearn.com/book-crc/code/sachs.interventional.txt.gz" +file_path = pooch.retrieve( + url=url, + known_hash="md5:39ee257f7eeb94cb60e6177cf80c9544", +) + +df = pd.read_csv(file_path, delimiter=" ") + +# the ground-truth dag is shown here: XXX: comment in when errors are fixed +ground_truth_dag = bnlearn.import_DAG("sachs", verbose=False) +fig = bnlearn.plot(ground_truth_dag) + +# .. note:: +# The Sachs dataset has previously been preprocessed, and the steps are described +# in bnlearn, at the web-page https://www.bnlearn.com/research/sachs05/. +print(df.head()) +print(df.shape) + +# %% +# Preprocess the dataset +# ---------------------- +# Since the data is one dataframe, we need to process it into a form +# that is acceptable by dodiscover's :class:`constraint.PsiFCI` algorithm. We +# will form a list of separate dataframes. +unique_ints = df["INT"].unique() + +# get the list of intervention targets and list of dataframe associated with each intervention +intervention_targets = [df.columns[idx] for idx in unique_ints] +data_cols = [col for col in df.columns if col != "INT"] +data = [] +domain_ids = np.array([1, 1, 1, 1, 1, 1]) +for interv_idx in unique_ints: + _data = df[df["INT"] == interv_idx][data_cols] + data.append(_data) + +print(len(data), len(intervention_targets)) +# %% +# Setup constraint-based learner +# ------------------------------ +# Since we have access to interventional data, the causal discovery algorithm +# we will use that leverages CI and CD tests to estimate causal constraints +# is the Psi-FCI algorithm :footcite:`Jaber2020causal`. + +# Our dataset is comprised of discrete valued data, so we will utilize the +# G^2 (Chi-square) CI test. +ci_estimator = GSquareCITest(data_type="discrete") + +# Since our data is entirely discrete, we can also use the G^2 test as our +# CD test. +cd_estimator = GSquareCITest(data_type="discrete") + +alpha = 0.05 +learner = SFCI( + ci_estimator=ci_estimator, + cd_estimator=cd_estimator, + alpha=alpha, + max_combinations=10, + max_cond_set_size=4, + n_jobs=-1, +) + +# create context with information about the interventions +ctx_builder = make_context(create_using=InterventionalContextBuilder) +ctx: Context = ctx_builder.variables(data=data[0]).num_distributions(len(data)).build() + +# %% +# Run the learning process +# ------------------------ +# We have setup our causal context and causal discovery learner, so we will now +# run the algorithm using the :meth:`constraint.PsiFCI.learn_graph` API, which is similar to scikit-learn's +# `fit` design. All fitted attributes contain an underscore at the end. +learner = learner.learn_graph( + data, ctx, domain_indices=domain_ids, intervention_targets=intervention_targets +) + +# %% +# Analyze the results +# =================== +# Now that we have learned the graph, we will show it here. Note differences and similarities +# to the ground-truth DAG that is "assumed". Moreover, note that this reproduces Supplementary +# Figure 8 in :footcite:`Jaber2020causal`. +est_pag = learner.graph_ + +print(f"There are {len(est_pag.to_undirected().edges)} edges in the resulting PAG") + +# %% +# Visualize the full graph including the F-node +dot_graph = draw(est_pag, direction="LR") +dot_graph.render(outfile="psi_pag_full.png", view=True, cleanup=True) + +# %% +# Visualize the graph without the F-nodes +est_pag_no_fnodes = est_pag.subgraph(ctx.get_non_augmented_nodes()) +dot_graph = draw(est_pag_no_fnodes, direction="LR") +dot_graph.render(outfile="psi_pag.png", view=True, cleanup=True) + +# Interpretation +# -------------- +# Looking at the supplemental figure 8b in :footcite:`Jaber2020causal`, we see that the +# learned PAG matches quite well. + +# References +# ---------- +# .. footbibliography:: diff --git a/examples/constraint/plot_simulation_sfci.ipynb b/examples/constraint/plot_simulation_sfci.ipynb new file mode 100644 index 000000000..aa91912a5 --- /dev/null +++ b/examples/constraint/plot_simulation_sfci.ipynb @@ -0,0 +1,2904 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "3c6609d2-4b85-456c-8430-db2061788cda", + "metadata": {}, + "source": [ + "S-FCI Algorithm for Structure Learning on Simulated Data\n", + "========================================================\n", + "\n", + "The SFCI algorithm is shown to be a generalization of the FCI, I-FCI and $\\Psi$-FCI algorithms, which is\n", + "capable of structure learning over observational and/or interventional data collected across multiple\n", + "environments.\n", + "\n", + "In this example, we will leverage [pywhy_graphs](simulation_example) to simulate a discrete causal Bayesian\n", + "network, which acts as our causal selection diagram. We will also simulate different interventions and environments\n", + "to demonstrate how additional data that arises from different domains are useful." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "d37c74db-b424-4297-9303-cd718cca0112", + "metadata": {}, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "290503ef-96a6-433a-b01c-e10ea04ba6f7", + "metadata": {}, + "outputs": [], + "source": [ + "from pprint import pprint\n", + "\n", + "import bnlearn\n", + "import networkx as nx\n", + "import numpy as np\n", + "import pandas as pd\n", + "import scipy.stats\n", + "from pywhy_graphs.functional import sample_from_graph\n", + "from pywhy_graphs.functional.discrete import (\n", + " apply_discrete_soft_intervention,\n", + " make_random_discrete_graph,\n", + ")\n", + "from pywhy_graphs.viz import draw\n", + "\n", + "from dodiscover import (\n", + " FCI,\n", + " PC,\n", + " SFCI,\n", + " Context,\n", + " InterventionalContextBuilder,\n", + " PsiFCI,\n", + " make_context,\n", + ")\n", + "from dodiscover.ci import CategoricalCITest, GSquareCITest, Oracle" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "3ec27151-eee2-49a3-bf74-0cc671ecac7c", + "metadata": {}, + "outputs": [], + "source": [ + "from pgmpy.factors.discrete import JointProbabilityDistribution\n", + "from pgmpy.factors.discrete.CPD import TabularCPD\n", + "\n", + "\n", + "def print_full(cpd):\n", + " backup = TabularCPD._truncate_strtable\n", + " TabularCPD._truncate_strtable = lambda self, x: x\n", + " print(cpd)\n", + " TabularCPD._truncate_strtable = backup" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "7ebd007f-de4a-41d0-951a-057c7d4aa627", + "metadata": {}, + "outputs": [], + "source": [ + "n_jobs = -1" + ] + }, + { + "cell_type": "markdown", + "id": "7c1369c7-fb43-4ca8-8189-4f82dfe2e2ea", + "metadata": {}, + "source": [ + "Draw a graph\n", + "------------\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "8de9d34b-cc5f-4acb-8c49-a7a05fbb7b43", + "metadata": {}, + "outputs": [], + "source": [ + "edge_list = [\n", + " (\"A\", \"B\"),\n", + " (\"B\", \"C\"),\n", + " (\"C\", \"D\"),\n", + " (\"B\", \"D\"),\n", + " (\"X\", \"A\"),\n", + " (\"X\", \"C\"),\n", + " (\"C\", \"W\"),\n", + "]\n", + "G = nx.DiGraph()\n", + "\n", + "G.add_edges_from(edge_list)" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "cba43643-7f55-46ff-9113-fd42c1b119b0", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "True\n" + ] + } + ], + "source": [ + "print(type(G))\n", + "# print(G.undirected_edges)\n", + "print(isinstance(G, type(nx.Graph())))" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "84b152fd-0a9b-410e-af5f-4bbe7b041744", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "A\n", + "\n", + "A\n", + "\n", + "\n", + "\n", + "B\n", + "\n", + "B\n", + "\n", + "\n", + "\n", + "A->B\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X\n", + "\n", + "X\n", + "\n", + "\n", + "\n", + "X->A\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C\n", + "\n", + "C\n", + "\n", + "\n", + "\n", + "X->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "B->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "D\n", + "\n", + "D\n", + "\n", + "\n", + "\n", + "B->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "W\n", + "\n", + "W\n", + "\n", + "\n", + "\n", + "C->W\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# get the layout position for the graph G using networkx\n", + "pos_G = nx.spring_layout(G, k=1)\n", + "\n", + "dot_graph = draw(G, pos=pos_G, prog=\"neato\")\n", + "dot_graph.render(outfile=\"true_graph.png\", view=False, cleanup=True, engine=\"neato\")\n", + "\n", + "dot_graph" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "9bde340d-d05a-430e-8663-23c824e25b6f", + "metadata": {}, + "outputs": [], + "source": [ + "node_order = list(nx.topological_sort(G))" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "1a781934-c086-487b-8fb9-b1bb63fdfa8a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[0.10793277 0.21842778]\n", + "digraph {\n", + "\tA [height=.5 pos=\"0.10793277386412264,0.21842778372323665\" shape=square width=.5]\n", + "\tX -> A [color=blue prog=neato]\n", + "\tB [height=.5 pos=\"-0.5083602744222117,-0.2879750418138882\" shape=square width=.5]\n", + "\tA -> B [color=blue prog=neato]\n", + "\tC [height=.5 pos=\"0.06927561966926396,-0.20876747677347904\" shape=square width=.5]\n", + "\tB -> C [color=blue prog=neato]\n", + "\tX -> C [color=blue prog=neato]\n", + "\tD [height=.5 pos=\"-0.9358469497119339,0.17196159773885009\" shape=square width=.5]\n", + "\tC -> D [color=blue prog=neato]\n", + "\tB -> D [color=blue prog=neato]\n", + "\tX [height=.5 pos=\"0.26699883060075913,0.5517523955094646\" shape=square width=.5]\n", + "\tW [height=.5 pos=\"1.0,-0.44539925838418415\" shape=square width=.5]\n", + "\tC -> W [color=blue prog=neato]\n", + "}\n", + "\n" + ] + } + ], + "source": [ + "print(pos_G[\"A\"])\n", + "print(dot_graph)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "d3f8ac7f-8100-4981-87ae-c3ae1af470b8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{}\n" + ] + } + ], + "source": [ + "print(dot_graph.node_attr)" + ] + }, + { + "cell_type": "markdown", + "id": "391415ef-d4ed-4f1b-a868-15b856561bf7", + "metadata": {}, + "source": [ + "Define the distributions for nodes and functions for edges\n", + "----------------------------------------------------------\n", + "\n", + "Now, we can parametrize the graph fully, so that sampling from it is possible." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "f1306805-a77f-4515-be77-de6ae630f8e9", + "metadata": {}, + "outputs": [], + "source": [ + "cardinality_lims = {node: [2, 4] for node in G.nodes}\n", + "weight_lims = {node: [1, 100] for node in G.nodes}\n", + "noise_ratio_lims = {node: [0.1, 0.1] for node in G.nodes}\n", + "seed = 1234" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "8e116785-4421-4657-89f2-15cd163bfa99", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DiGraph with 6 nodes and 7 edges\n" + ] + } + ], + "source": [ + "G = make_random_discrete_graph(\n", + " G,\n", + " cardinality_lims=cardinality_lims,\n", + " weight_lims=weight_lims,\n", + " noise_ratio_lims=noise_ratio_lims,\n", + " random_state=seed,\n", + " overwrite=True,\n", + ")\n", + "\n", + "obs_G = G.copy()\n", + "print(G)" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "60e9cb9a-d941-4cc0-bca0-decd7830756a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[3 3 3]\n" + ] + } + ], + "source": [ + "# we can extract the conditional probability table for each node, which is a function of its parents\n", + "node_dict = G.nodes[\"C\"]\n", + "\n", + "print_full(node_dict[\"cpd\"].cardinality)" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "30f2e2a4-667b-45e5-b9f3-c87d0d209854", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "inside: [('A', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x28e4f4670>, 'exogenous_distribution': . at 0x1768a8790>, 'parent_function': .parent_func at 0x1768a8430>}), ('B', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8700>, 'exogenous_distribution': . at 0x1768a85e0>, 'parent_function': .parent_func at 0x1768a8550>}), ('C', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a83a0>, 'exogenous_distribution': . at 0x1768a84c0>, 'parent_function': .parent_func at 0x1768a8310>}), ('D', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8820>, 'exogenous_distribution': . at 0x1768a88b0>, 'parent_function': .parent_func at 0x1768a8940>}), ('X', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 1.0, 'exogenous_function': . at 0x104e5bee0>, 'exogenous_distribution': .parent_func at 0x1051b5160>}), ('W', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a89d0>, 'exogenous_distribution': . at 0x1768a8a60>, 'parent_function': .parent_func at 0x1768a8af0>})]\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x28e4f4670>, 'exogenous_distribution': . at 0x1768a8790>, 'parent_function': .parent_func at 0x1768a8430>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8700>, 'exogenous_distribution': . at 0x1768a85e0>, 'parent_function': .parent_func at 0x1768a8550>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a83a0>, 'exogenous_distribution': . at 0x1768a84c0>, 'parent_function': .parent_func at 0x1768a8310>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8820>, 'exogenous_distribution': . at 0x1768a88b0>, 'parent_function': .parent_func at 0x1768a8940>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 1.0, 'exogenous_function': . at 0x104e5bee0>, 'exogenous_distribution': .parent_func at 0x1051b5160>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a89d0>, 'exogenous_distribution': . at 0x1768a8a60>, 'parent_function': .parent_func at 0x1768a8af0>}\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "cbb992ae11824c748a2ff4d561458afa", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + " 0%| | 0/6 [00:00\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ABCDXW
0210012
1112122
2122210
3112221
4122121
\n", + "" + ], + "text/plain": [ + " A B C D X W\n", + "0 2 1 0 0 1 2\n", + "1 1 1 2 1 2 2\n", + "2 1 2 2 2 1 0\n", + "3 1 1 2 2 2 1\n", + "4 1 2 2 1 2 1" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(40000, 6)\n" + ] + } + ], + "source": [ + "display(df.head())\n", + "print(df.shape)" + ] + }, + { + "cell_type": "markdown", + "id": "49daab35-2f8b-417f-9a2b-0710f42954f4", + "metadata": {}, + "source": [ + "Causal discovery: Observational data in a single domain\n", + "-------------------------------------------------------\n", + "\n", + "First, we demonstrate how SFCI is exactly the same as the FCI algorithm in the context of single-domain observational data." + ] + }, + { + "cell_type": "code", + "execution_count": 250, + "id": "0f341994-ccb8-44c7-a1ac-d4af8513797f", + "metadata": {}, + "outputs": [], + "source": [ + "ctx_builder = make_context()\n", + "ctx: Context = (\n", + " ctx_builder.variables(data=df)\n", + " # .obs_distribution(True)\n", + " .build()\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 251, + "id": "760c6d42-8bea-404b-987d-8a7c19699b37", + "metadata": {}, + "outputs": [], + "source": [ + "ci_estimator = CategoricalCITest()" + ] + }, + { + "cell_type": "code", + "execution_count": 252, + "id": "edefd463-f795-4ef4-a17a-dc762452b5aa", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 252, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# now let's run FCI\n", + "fci_learner = FCI(\n", + " ci_estimator=ci_estimator,\n", + " n_jobs=n_jobs,\n", + " max_cond_set_size=2,\n", + " max_combinations=None,\n", + " # alpha=0.5,\n", + ")\n", + "fci_learner.learn_graph(df, ctx)" + ] + }, + { + "cell_type": "code", + "execution_count": 258, + "id": "4807951b-197d-4137-88bb-c5b49be48255", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "W\n", + "\n", + "W\n", + "\n", + "\n", + "\n", + "B\n", + "\n", + "B\n", + "\n", + "\n", + "\n", + "C\n", + "\n", + "C\n", + "\n", + "\n", + "\n", + "B->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "D\n", + "\n", + "D\n", + "\n", + "\n", + "\n", + "B->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "A\n", + "\n", + "A\n", + "\n", + "\n", + "\n", + "B->A\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->W\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X\n", + "\n", + "X\n", + "\n", + "\n", + "\n", + "X->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X->A\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 258, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "graph = fci_learner.graph_\n", + "# draw(graph, name=\"FCI graph\", pos=pos_G, prog=\"neato\")\n", + "dot_graph = draw(\n", + " graph,\n", + " # name=\"FCI graph\",\n", + " pos=pos_G, # prog=\"neato\" # , node_order=node_order\n", + ")\n", + "\n", + "dot_graph.render(\n", + " outfile=\"./fci_obs.png\",\n", + " view=False,\n", + " cleanup=True,\n", + " # engine=\"neato\"\n", + ")\n", + "dot_graph" + ] + }, + { + "cell_type": "code", + "execution_count": 254, + "id": "1739c748-c2e1-4025-9234-0d3d751ef57c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "digraph {\n", + "\tgraph [label=\"FCI graph\"]\n", + "\tX [height=.5 pos=\"0.7391505362251583,0.11699328808194805!\" shape=square width=.5]\n", + "\tA [height=.5 pos=\"1.0,-0.3140320449124699!\" shape=square width=.5]\n", + "\tB [height=.5 pos=\"-0.08608632483664021,0.2688828567162253!\" shape=square width=.5]\n", + "\tC [height=.5 pos=\"-0.22678494014014539,-0.37720753552784386!\" shape=square width=.5]\n", + "\tD [height=.5 pos=\"-0.8244997069657383,-0.42188428497794495!\" shape=square width=.5]\n", + "\tW [height=.5 pos=\"-0.6017795642826345,0.7272477206200854!\" shape=square width=.5]\n", + "\tB -> A [arrowhead=odot arrowtail=normal color=green dir=both]\n", + "\tC -> W [arrowhead=odot arrowtail=normal color=green dir=both]\n", + "\tX -> A [arrowhead=odot arrowtail=odot color=green dir=both]\n", + "\tB -> D [color=red dir=both]\n", + "\tB -> C [color=blue engine=neato]\n", + "\tX -> C [color=blue engine=neato]\n", + "\tC -> D [color=blue engine=neato]\n", + "}\n", + "\n" + ] + } + ], + "source": [ + "print(dot_graph)" + ] + }, + { + "cell_type": "markdown", + "id": "75c46ae3-e676-4e0f-b0e7-02320e46734d", + "metadata": {}, + "source": [ + "We see that both graphs are exactly the same, as we have shown theoretically, the SFCI algorithm is a generalization\n", + "of the FCI algorithm. Moreover, the S-PAG is a valid generalization of the PAG, and in this case, both graphs align perfectly." + ] + }, + { + "cell_type": "markdown", + "id": "38d8d65e-daed-44c5-8d76-946b6cff18e7", + "metadata": {}, + "source": [ + "Causal Discovery: Interventional data in a single domain\n", + "--------------------------------------------------------\n", + "\n", + "Now, when we add interventional distributions, we expect that there will be additional\n", + "edges that we can orient due to the extra information when comparing different distributions." + ] + }, + { + "cell_type": "code", + "execution_count": 66, + "id": "701726ae-1c84-4efc-8ce8-bd7eae39ce83", + "metadata": {}, + "outputs": [], + "source": [ + "n_sample_ints = 30_000" + ] + }, + { + "cell_type": "code", + "execution_count": 67, + "id": "262ca3c7-2971-45c9-98e7-18c7ca18a715", + "metadata": {}, + "outputs": [], + "source": [ + "rng = np.random.default_rng(seed)" + ] + }, + { + "cell_type": "code", + "execution_count": 68, + "id": "1b327575-69b0-42c8-a7d9-5dcd1bb77998", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "inside: [('A', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x28e4f4670>, 'exogenous_distribution': . at 0x1768a8790>, 'parent_function': .parent_func at 0x1768a8430>}), ('B', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8700>, 'exogenous_distribution': . at 0x1768a85e0>, 'parent_function': .parent_func at 0x1768a8550>}), ('C', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a83a0>, 'exogenous_distribution': . at 0x1768a84c0>, 'parent_function': .parent_func at 0x1768a8310>}), ('D', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.0, 'exogenous_function': . at 0x1768a8820>, 'exogenous_distribution': . at 0x1768a88b0>, 'parent_function': .parent_func at 0x29011d820>}), ('X', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 1.0, 'exogenous_function': . at 0x104e5bee0>, 'exogenous_distribution': .parent_func at 0x1051b5160>}), ('W', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a89d0>, 'exogenous_distribution': . at 0x1768a8a60>, 'parent_function': .parent_func at 0x1768a8af0>})]\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x28e4f4670>, 'exogenous_distribution': . at 0x1768a8790>, 'parent_function': .parent_func at 0x1768a8430>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8700>, 'exogenous_distribution': . at 0x1768a85e0>, 'parent_function': .parent_func at 0x1768a8550>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a83a0>, 'exogenous_distribution': . at 0x1768a84c0>, 'parent_function': .parent_func at 0x1768a8310>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.0, 'exogenous_function': . at 0x1768a8820>, 'exogenous_distribution': . at 0x1768a88b0>, 'parent_function': .parent_func at 0x29011d820>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 1.0, 'exogenous_function': . at 0x104e5bee0>, 'exogenous_distribution': .parent_func at 0x1051b5160>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a89d0>, 'exogenous_distribution': . at 0x1768a8a60>, 'parent_function': .parent_func at 0x1768a8af0>}\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "e29e3fecb9544024877bee26611e512b", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + " 0%| | 0/6 [00:00\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "W\n", + "\n", + "W\n", + "\n", + "\n", + "\n", + "B\n", + "\n", + "B\n", + "\n", + "\n", + "\n", + "D\n", + "\n", + "D\n", + "\n", + "\n", + "\n", + "B->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "A\n", + "\n", + "A\n", + "\n", + "\n", + "\n", + "B->A\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C\n", + "\n", + "C\n", + "\n", + "\n", + "\n", + "B->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X\n", + "\n", + "X\n", + "\n", + "\n", + "\n", + "A->X\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->W\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X->C\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 72, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "est_pag = int_learner.graph_.subgraph(ctx.get_non_augmented_nodes())\n", + "\n", + "# %%\n", + "# Visualize the full graph including the F-node\n", + "dot_graph = draw(est_pag, pos=pos_G)\n", + "\n", + "dot_graph.render(outfile=\"./psifci_obsandint.png\", view=False, cleanup=True)\n", + "dot_graph" + ] + }, + { + "cell_type": "code", + "execution_count": 74, + "id": "0c9c5d55-534c-4aaf-b4ca-71132878bbbc", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "W\n", + "\n", + "W\n", + "\n", + "\n", + "\n", + "B\n", + "\n", + "B\n", + "\n", + "\n", + "\n", + "C\n", + "\n", + "C\n", + "\n", + "\n", + "\n", + "B->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "D\n", + "\n", + "D\n", + "\n", + "\n", + "\n", + "B->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "A\n", + "\n", + "A\n", + "\n", + "\n", + "\n", + "B->A\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->W\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 0)\n", + "\n", + "('F', 0)\n", + "\n", + "\n", + "\n", + "('F', 0)->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X\n", + "\n", + "X\n", + "\n", + "\n", + "\n", + "X->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X->A\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 74, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "est_pag = int_learner.graph_\n", + "\n", + "# %%\n", + "# Visualize the full graph including the F-node\n", + "draw(est_pag, pos=pos_G)" + ] + }, + { + "cell_type": "code", + "execution_count": 52, + "id": "58416873-6d8f-4a2e-880f-43c9d948db1c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{('A', 'B'): 'collider',\n", + " ('A', 'C'): 'rule9',\n", + " ('B', 'C'): 'rule 1: A *-> B o-* C',\n", + " ('B', 'D'): 'rule2',\n", + " ('C', 'B'): 'rule 1: A *-> B o-* C',\n", + " ('C', 'D'): 'rule 1: W *-> C o-* D',\n", + " ('D', 'B'): 'collider',\n", + " ('D', 'C'): 'rule 1: W *-> C o-* D',\n", + " ('W', 'C'): 'collider',\n", + " ('X', 'C'): 'collider'}\n" + ] + } + ], + "source": [ + "pprint(int_learner.debug_map)" + ] + }, + { + "cell_type": "markdown", + "id": "0a3dc765-a4cd-46bf-aa7c-3847041419a7", + "metadata": {}, + "source": [ + "We see that additional edges can be oriented with the presence of interventional data. Moreover, the SFCI algorithm perfectly \n", + "replicates and builds on top of the interventional data." + ] + }, + { + "cell_type": "markdown", + "id": "1a15289e-32ee-45ab-a5ff-a6f3aeedf4b3", + "metadata": {}, + "source": [ + "Causal Discovery: Observational data across multiple domains\n", + "------------------------------------------------------------\n", + "\n", + "In the SFCI paper, it is shown that the $\\Psi$-FCI algorithm is equivalent to the SFCI algorithm when there is\n", + "observational data across multiple domains, where the F-nodes can be seen as equivalent to S-nodes.\n", + "\n", + "We can leverage the same distributions that were intervened on because the change in domain can be seen\n", + "as an unknown-target intervention that occurs over those variables. That is, nature changes the distribution (CPD)\n", + "of the intervened variables, but we do not know where nature intervened. Thus conceptually, we see observational\n", + "data across multiple domains is similar to the setting with unknown-target interventional data within\n", + "a single domain. \n", + "\n", + "We will see later that when we have interventional data across multiple domains, the story becomes more complex\n", + "(and as a result, more interesting)." + ] + }, + { + "cell_type": "code", + "execution_count": 75, + "id": "f3478f02-7401-478c-b2bc-86058af4efc6", + "metadata": {}, + "outputs": [], + "source": [ + "# create context with information about the interventions\n", + "ctx_builder = make_context(create_using=InterventionalContextBuilder)\n", + "ctx: Context = (\n", + " ctx_builder.variables(data=data[0])\n", + " .num_distributions(len(data))\n", + " .obs_distribution(True)\n", + " .build()\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 76, + "id": "deceeeac-3866-4ecd-9ca7-b1206a11b4cd", + "metadata": {}, + "outputs": [], + "source": [ + "int_learner = PsiFCI(\n", + " ci_estimator=ci_estimator,\n", + " cd_estimator=ci_estimator,\n", + " max_cond_set_size=2,\n", + " n_jobs=-1,\n", + " debug=True,\n", + ")\n", + "\n", + "int_learner = int_learner.learn_graph(data, ctx)" + ] + }, + { + "cell_type": "code", + "execution_count": 245, + "id": "4116afe8-8dff-4487-930f-277bbd382301", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "B\n", + "\n", + "B\n", + "\n", + "\n", + "\n", + "D\n", + "\n", + "D\n", + "\n", + "\n", + "\n", + "B->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C\n", + "\n", + "C\n", + "\n", + "\n", + "\n", + "B->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "D->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "W\n", + "\n", + "W\n", + "\n", + "\n", + "\n", + "W->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 5)\n", + "\n", + "('F', 5)\n", + "\n", + "\n", + "\n", + "('F', 5)->W\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 2)\n", + "\n", + "('F', 2)\n", + "\n", + "\n", + "\n", + "('F', 2)->W\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 2)->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X\n", + "\n", + "X\n", + "\n", + "\n", + "\n", + "('F', 2)->X\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 1)\n", + "\n", + "('F', 1)\n", + "\n", + "\n", + "\n", + "('F', 1)->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 1)->X\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 4)\n", + "\n", + "('F', 4)\n", + "\n", + "\n", + "\n", + "('F', 4)->W\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 4)->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 0)\n", + "\n", + "('F', 0)\n", + "\n", + "\n", + "\n", + "('F', 0)->X\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "A\n", + "\n", + "A\n", + "\n", + "\n", + "\n", + "A->B\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 3)\n", + "\n", + "('F', 3)\n", + "\n", + "\n", + "\n", + "('F', 3)->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X->A\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 245, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "est_pag = int_learner.graph_\n", + "\n", + "# %%\n", + "# Visualize the full graph including the F-node\n", + "draw(est_pag, direction=\"LR\")" + ] + }, + { + "cell_type": "code", + "execution_count": 48, + "id": "007ac3d5-c259-4442-9bdc-1bfe36901c7f", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C\n", + "\n", + "C\n", + "\n", + "\n", + "\n", + "W\n", + "\n", + "W\n", + "\n", + "\n", + "\n", + "C->W\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "D\n", + "\n", + "D\n", + "\n", + "\n", + "\n", + "C->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "B\n", + "\n", + "B\n", + "\n", + "\n", + "\n", + "B->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "B->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X\n", + "\n", + "X\n", + "\n", + "\n", + "\n", + "X->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "A\n", + "\n", + "A\n", + "\n", + "\n", + "\n", + "X->A\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "A->B\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 48, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "est_pag = int_learner.graph_.subgraph(ctx.get_non_augmented_nodes())\n", + "\n", + "# %%\n", + "# Visualize the full graph including the F-node\n", + "dot_graph = draw(est_pag, direction=\"LR\")\n", + "\n", + "dot_graph.render(outfile=\"./sfci_obs.png\", view=False, cleanup=True)\n", + "dot_graph" + ] + }, + { + "cell_type": "code", + "execution_count": 256, + "id": "f7c949df-f235-4bb7-a6fd-2738c7213d30", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[set(), set(), set(), set()]\n" + ] + } + ], + "source": [ + "intervention_targets = [set()] * len(data)\n", + "print(intervention_targets)" + ] + }, + { + "cell_type": "code", + "execution_count": 274, + "id": "96f28add-3517-4947-a0d2-aa5bcdd7a25d", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{('S', 0): frozenset({0, 1}), ('S', 1): frozenset({0, 2}), ('S', 2): frozenset({0, 3}), ('S', 3): frozenset({1, 2}), ('S', 4): frozenset({1, 3}), ('S', 5): frozenset({2, 3})}\n" + ] + } + ], + "source": [ + "print(learner.context_.state_variable(\"node_domain_map\"))" + ] + }, + { + "cell_type": "markdown", + "id": "8cbea675-a3ad-4796-95ad-6c7a58ce648b", + "metadata": {}, + "source": [ + "Now, we see that SFCI is equivalent to $\\Psi$-FCI in the presence of observational data that spans different domains as shown in the theoretical results of the SFCI paper.\n", + "The benefit of explicitly noting the fact that the observational data come from different domains is that we now have knowledge of the S-nodes that underlie the causal\n", + "selection diagram. Of course, the S-node edges are simply estimates and moreover it is only part of the Markov equivalence class, since inducing paths may cause extra edges.\n", + "However, the information of the S-nodes now provide the user how they should expect distributions and causal knowledge to change when going between different domains.\n", + "\n", + "For example, if we take a look at S-node (S, 2), representing domains 0 and 3, then this induces a potential change in the function, or exogenous variable distribution for variable D. However, because\n", + "of the colliders, we see that we could translate causal knowledge between domains 0 and 3 as long as we do not open up a path from (S, 2) node to the rest of the variables." + ] + }, + { + "cell_type": "markdown", + "id": "c002dcec-9fae-414d-9c81-ac94c337a995", + "metadata": {}, + "source": [ + "Causal Discovery: Observational data and interventional data across multiple domains\n", + "------------------------------------------------------------------------------------\n", + "\n", + "As we saw in the previous sections, SFCI is capable of exactly producing the same results as the FCI, and I/$\\Psi$-FCI algorithms given datasets in a single domain. In addition, when analyzing purely observational data coming from multiple domains, we see an equivalence in the results between SFCI and $\\Psi$FCI.\n", + "\n", + "Next, we will show how having both observational and interventional data from different domains can allow us to learn more." + ] + }, + { + "cell_type": "markdown", + "id": "105e9828-be6d-4c37-8b85-fc8d62954631", + "metadata": {}, + "source": [ + "First we will simulate data that comes from multipl domains using a similar procedure." + ] + }, + { + "cell_type": "code", + "execution_count": 294, + "id": "8115bfcd-209e-4c71-b1bd-87639955d134", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[frozenset(), ['D']]\n", + "[frozenset(), ['D']]\n", + "[frozenset(), ('D',)]\n" + ] + } + ], + "source": [ + "intervention_targets = targets.copy()\n", + "intervention_targets.insert(0, frozenset())\n", + "print(intervention_targets)\n", + "\n", + "domain_one_targets = intervention_targets.copy()\n", + "domain_two_targets = [frozenset(), (\"D\",)]\n", + "\n", + "print(domain_one_targets)\n", + "print(domain_two_targets)" + ] + }, + { + "cell_type": "code", + "execution_count": 295, + "id": "56b79f04-c6ac-4d42-a7a2-b165ff212abb", + "metadata": { + "scrolled": true + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "inside: [('A', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x28e4f4670>, 'exogenous_distribution': . at 0x1768a8790>, 'parent_function': .parent_func at 0x1768a8430>}), ('B', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8700>, 'exogenous_distribution': . at 0x1768a85e0>, 'parent_function': .parent_func at 0x1768a8550>}), ('C', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a83a0>, 'exogenous_distribution': . at 0x1768a84c0>, 'parent_function': .parent_func at 0x1768a8310>}), ('D', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.0, 'exogenous_function': . at 0x1768a8820>, 'exogenous_distribution': . at 0x1768a88b0>, 'parent_function': .parent_func at 0x2929b2700>}), ('X', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 1.0, 'exogenous_function': . at 0x104e5bee0>, 'exogenous_distribution': .parent_func at 0x1051b5160>}), ('W', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a89d0>, 'exogenous_distribution': . at 0x1768a8a60>, 'parent_function': .parent_func at 0x1768a8af0>})]\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x28e4f4670>, 'exogenous_distribution': . at 0x1768a8790>, 'parent_function': .parent_func at 0x1768a8430>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8700>, 'exogenous_distribution': . at 0x1768a85e0>, 'parent_function': .parent_func at 0x1768a8550>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a83a0>, 'exogenous_distribution': . at 0x1768a84c0>, 'parent_function': .parent_func at 0x1768a8310>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.0, 'exogenous_function': . at 0x1768a8820>, 'exogenous_distribution': . at 0x1768a88b0>, 'parent_function': .parent_func at 0x2929b2700>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 1.0, 'exogenous_function': . at 0x104e5bee0>, 'exogenous_distribution': .parent_func at 0x1051b5160>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a89d0>, 'exogenous_distribution': . at 0x1768a8a60>, 'parent_function': .parent_func at 0x1768a8af0>}\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "7cd493cdf22f401685e2b361bf29a050", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + " 0%| | 0/6 [00:00, 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x28e4f4670>, 'exogenous_distribution': . at 0x1768a8790>, 'parent_function': .parent_func at 0x1768a8430>}), ('B', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8700>, 'exogenous_distribution': . at 0x1768a85e0>, 'parent_function': .parent_func at 0x1768a8550>}), ('C', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.0, 'exogenous_function': . at 0x1768a83a0>, 'exogenous_distribution': . at 0x1768a84c0>, 'parent_function': .parent_func at 0x2907fae50>}), ('D', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8820>, 'exogenous_distribution': . at 0x1768a88b0>, 'parent_function': .parent_func at 0x1768a8940>}), ('X', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 1.0, 'exogenous_function': . at 0x104e5bee0>, 'exogenous_distribution': .parent_func at 0x2907fa8b0>}), ('W', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a89d0>, 'exogenous_distribution': . at 0x1768a8a60>, 'parent_function': .parent_func at 0x1768a8af0>})]\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x28e4f4670>, 'exogenous_distribution': . at 0x1768a8790>, 'parent_function': .parent_func at 0x1768a8430>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8700>, 'exogenous_distribution': . at 0x1768a85e0>, 'parent_function': .parent_func at 0x1768a8550>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.0, 'exogenous_function': . at 0x1768a83a0>, 'exogenous_distribution': . at 0x1768a84c0>, 'parent_function': .parent_func at 0x2907fae50>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8820>, 'exogenous_distribution': . at 0x1768a88b0>, 'parent_function': .parent_func at 0x1768a8940>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 1.0, 'exogenous_function': . at 0x104e5bee0>, 'exogenous_distribution': .parent_func at 0x2907fa8b0>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a89d0>, 'exogenous_distribution': . at 0x1768a8a60>, 'parent_function': .parent_func at 0x1768a8af0>}\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "cb3a0740dad84013b1d524fdeb4a3c85", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + " 0%| | 0/6 [00:00, 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x28e4f4670>, 'exogenous_distribution': . at 0x1768a8790>, 'parent_function': .parent_func at 0x1768a8430>}), ('B', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8700>, 'exogenous_distribution': . at 0x1768a85e0>, 'parent_function': .parent_func at 0x1768a8550>}), ('C', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.0, 'exogenous_function': . at 0x1768a83a0>, 'exogenous_distribution': . at 0x1768a84c0>, 'parent_function': .parent_func at 0x2907fae50>}), ('D', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.0, 'exogenous_function': . at 0x1768a8820>, 'exogenous_distribution': . at 0x1768a88b0>, 'parent_function': .parent_func at 0x2907e8310>}), ('X', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 1.0, 'exogenous_function': . at 0x104e5bee0>, 'exogenous_distribution': .parent_func at 0x2907fa8b0>}), ('W', {'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a89d0>, 'exogenous_distribution': . at 0x1768a8a60>, 'parent_function': .parent_func at 0x1768a8af0>})]\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x28e4f4670>, 'exogenous_distribution': . at 0x1768a8790>, 'parent_function': .parent_func at 0x1768a8430>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a8700>, 'exogenous_distribution': . at 0x1768a85e0>, 'parent_function': .parent_func at 0x1768a8550>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.0, 'exogenous_function': . at 0x1768a83a0>, 'exogenous_distribution': . at 0x1768a84c0>, 'parent_function': .parent_func at 0x2907fae50>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.0, 'exogenous_function': . at 0x1768a8820>, 'exogenous_distribution': . at 0x1768a88b0>, 'parent_function': .parent_func at 0x2907e8310>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 1.0, 'exogenous_function': . at 0x104e5bee0>, 'exogenous_distribution': .parent_func at 0x2907fa8b0>}\n", + "{'cpd': , 'cardinality': 3, 'possible_values': [0, 1, 2], 'noise_ratio': 0.1, 'exogenous_function': . at 0x1768a89d0>, 'exogenous_distribution': . at 0x1768a8a60>, 'parent_function': .parent_func at 0x1768a8af0>}\n" + ] + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "fdb605b59a7447ad8bbaffb1bdb12d71", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + " 0%| | 0/6 [00:00" + ] + }, + "execution_count": 278, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "learner = SFCI(\n", + " ci_estimator=ci_estimator,\n", + " cd_estimator=ci_estimator,\n", + " max_cond_set_size=2,\n", + " n_jobs=-1,\n", + " debug=True,\n", + ")\n", + "\n", + "learner.learn_graph(\n", + " data,\n", + " ctx,\n", + " domain_indices=domain_ids,\n", + " intervention_targets=intervention_targets,\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 279, + "id": "09ba3ceb-5b6b-47c7-9eac-c4c73372a169", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[('F', 2), 'W', 'B', ('F', 1), ('F', 4), 'D', 'A', ('F', 3), 'C', 'X', ('F', 0), ('F', 5)]\n", + "['W', 'B', 'C', ('F', 0), 'X', 'D', 'A']\n", + "{'W', 'B', 'D', 'A', 'C', 'X'}\n" + ] + } + ], + "source": [ + "print(ctx.init_graph.nodes)\n", + "print(est_pag.nodes)\n", + "print(ctx.get_non_augmented_nodes())" + ] + }, + { + "cell_type": "code", + "execution_count": 303, + "id": "75fa96b0-61b0-4632-a471-b38a8e3499a8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[frozenset(), ['D'], frozenset(), ('D',)]\n" + ] + } + ], + "source": [ + "print(intervention_targets)" + ] + }, + { + "cell_type": "code", + "execution_count": 302, + "id": "d207660c-a336-4ef3-ac59-e57e37d9690a", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{('F', 0): (0, 1), ('F', 1): (0, 2), ('F', 2): (0, 3), ('F', 3): (1, 2), ('F', 4): (1, 3), ('F', 5): (2, 3)}\n" + ] + } + ], + "source": [ + "print(ctx.sigma_map)" + ] + }, + { + "cell_type": "code", + "execution_count": 280, + "id": "8afb55d8-313a-4f18-8da3-07ce89ab4b7d", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "W\n", + "\n", + "W\n", + "\n", + "\n", + "\n", + "B\n", + "\n", + "B\n", + "\n", + "\n", + "\n", + "D\n", + "\n", + "D\n", + "\n", + "\n", + "\n", + "B->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C\n", + "\n", + "C\n", + "\n", + "\n", + "\n", + "B->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "D->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "A\n", + "\n", + "A\n", + "\n", + "\n", + "\n", + "A->B\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->W\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X\n", + "\n", + "X\n", + "\n", + "\n", + "\n", + "X->A\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 2)\n", + "\n", + "('F', 2)\n", + "\n", + "\n", + "\n", + "('F', 2)->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 2)->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 2)->X\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 1)\n", + "\n", + "('F', 1)\n", + "\n", + "\n", + "\n", + "('F', 1)->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 1)->X\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 4)\n", + "\n", + "('F', 4)\n", + "\n", + "\n", + "\n", + "('F', 4)->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 4)->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 4)->X\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 3)\n", + "\n", + "('F', 3)\n", + "\n", + "\n", + "\n", + "('F', 3)->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 3)->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 3)->X\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 0)\n", + "\n", + "('F', 0)\n", + "\n", + "\n", + "\n", + "('F', 0)->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "('F', 5)\n", + "\n", + "('F', 5)\n", + "\n", + "\n", + "\n", + "('F', 5)->D\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 280, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "est_pag = learner.graph_\n", + "\n", + "draw(\n", + " est_pag,\n", + " # direction=\"LR\",\n", + " pos=pos_G,\n", + " # name=\"SFCI\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 281, + "id": "968fb5ed-d8e9-4a6a-a471-baea43d635ed", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "W\n", + "\n", + "W\n", + "\n", + "\n", + "\n", + "B\n", + "\n", + "B\n", + "\n", + "\n", + "\n", + "D\n", + "\n", + "D\n", + "\n", + "\n", + "\n", + "B->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C\n", + "\n", + "C\n", + "\n", + "\n", + "\n", + "B->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "D->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "A\n", + "\n", + "A\n", + "\n", + "\n", + "\n", + "A->B\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->W\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X\n", + "\n", + "X\n", + "\n", + "\n", + "\n", + "X->A\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X->C\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 281, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "est_pag = learner.graph_\n", + "est_pag_no_fnodes = est_pag.subgraph(ctx.get_non_augmented_nodes())\n", + "\n", + "dot_graph = draw(\n", + " est_pag_no_fnodes,\n", + " pos=pos_G,\n", + " # direction=\"LR\",\n", + " # name=\"SFCI\"\n", + ")\n", + "\n", + "dot_graph.render(outfile=\"./sfci_multidomain_obsandint.png\", view=False, cleanup=True)\n", + "dot_graph" + ] + }, + { + "cell_type": "code", + "execution_count": 282, + "id": "90587357-0d97-4502-ae74-665ddb5358af", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "{(('F', 0), 'D'): 'collider',\n", + " (('F', 1), 'C'): 'collider',\n", + " (('F', 1), 'X'): 'collider',\n", + " (('F', 2), 'C'): 'collider',\n", + " (('F', 2), 'D'): 'collider',\n", + " (('F', 2), 'X'): 'collider',\n", + " (('F', 3), 'C'): 'collider',\n", + " (('F', 4), 'C'): 'collider',\n", + " (('F', 4), 'D'): 'collider',\n", + " (('F', 4), 'X'): 'collider',\n", + " ('A', 'C'): 'rule9',\n", + " ('A', 'X'): \"rule 1: ('F', 2) *-> X o-* A\",\n", + " ('B', 'A'): 'rule 1: X *-> A o-* B',\n", + " ('C', 'B'): 'rule 1: A *-> B o-* C',\n", + " (('F', 5), 'D'): 'Rule 11',\n", + " ('A', 'B'): 'rule 1: X *-> A o-* B',\n", + " ('B', 'C'): 'rule 1: A *-> B o-* C',\n", + " ('B', 'D'): 'collider',\n", + " ('C', 'D'): 'collider',\n", + " ('D', 'B'): 'collider',\n", + " ('D', 'C'): 'collider',\n", + " ('W', 'C'): 'collider',\n", + " ('X', 'C'): 'collider',\n", + " (('F', 3), 'D'): 'collider',\n", + " (('F', 3), 'X'): 'collider',\n", + " ('X', 'A'): \"rule 1: ('F', 2) *-> X o-* A\"}\n" + ] + } + ], + "source": [ + "pprint(learner.debug_map)" + ] + }, + { + "cell_type": "markdown", + "id": "3118d12f-3ee1-4577-9bf6-30446c7af10f", + "metadata": {}, + "source": [ + "# Stacking all the obs. data together" + ] + }, + { + "cell_type": "code", + "execution_count": 283, + "id": "c8b30f3d-e571-46c0-b384-91ea53549fd6", + "metadata": {}, + "outputs": [], + "source": [ + "# stack all the data together and then run through FCI\n", + "stacked_df = pd.concat((domain_one_obs, domain_two_obs), axis=0)" + ] + }, + { + "cell_type": "code", + "execution_count": 284, + "id": "3869613c-8b5b-4bd1-977c-2abba38503a1", + "metadata": {}, + "outputs": [], + "source": [ + "ctx_builder = make_context()\n", + "ctx: Context = (\n", + " ctx_builder.variables(data=stacked_df)\n", + " # .obs_distribution(True)\n", + " .build()\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 285, + "id": "918546db-12ad-49b1-85bd-40ab1d6ea884", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 285, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# now let's run FCI\n", + "fci_learner = FCI(\n", + " ci_estimator=ci_estimator,\n", + " n_jobs=n_jobs,\n", + " max_cond_set_size=2,\n", + " max_combinations=None,\n", + " # alpha=0.5,\n", + ")\n", + "fci_learner.learn_graph(stacked_df, ctx)" + ] + }, + { + "cell_type": "code", + "execution_count": 286, + "id": "7e13fcca-1b3d-4960-a75d-08b675f80e81", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "W\n", + "\n", + "W\n", + "\n", + "\n", + "\n", + "B\n", + "\n", + "B\n", + "\n", + "\n", + "\n", + "C\n", + "\n", + "C\n", + "\n", + "\n", + "\n", + "B->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "D\n", + "\n", + "D\n", + "\n", + "\n", + "\n", + "B->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "A\n", + "\n", + "A\n", + "\n", + "\n", + "\n", + "B->A\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->W\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X\n", + "\n", + "X\n", + "\n", + "\n", + "\n", + "X->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X->A\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 286, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "est_pag = fci_learner.graph_\n", + "\n", + "dot_graph = draw(\n", + " est_pag,\n", + " direction=\"LR\",\n", + ")\n", + "\n", + "\n", + "dot_graph.render(outfile=\"./fci_multidomain_stacked_obs.png\", view=False, cleanup=True)\n", + "dot_graph" + ] + }, + { + "cell_type": "markdown", + "id": "7eaf60a1-7fb6-4acc-871b-49c3a58fbe36", + "metadata": {}, + "source": [ + "# Stacking all the obs. and interventional data together" + ] + }, + { + "cell_type": "code", + "execution_count": 287, + "id": "13003a43-4b0c-459c-9308-1b90708f048b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2\n" + ] + } + ], + "source": [ + "# stack all observational and interventional data together and run through Psi-FCI\n", + "stacked_data = [\n", + " stacked_df.copy(), # stacked obs\n", + " pd.concat((domain_one_data[1], domain_two_data[1]), axis=0),\n", + "]\n", + "\n", + "# stacked_data.extend(domain_one_data[1:])\n", + "# stacked_data.extend(domain_two_data[1:])\n", + "\n", + "print(len(stacked_data))" + ] + }, + { + "cell_type": "code", + "execution_count": 288, + "id": "96a15459-ffcc-4dfe-8e5b-c3a55cc79c80", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[frozenset(), ['D']]\n", + "[frozenset(), ('D',)]\n" + ] + } + ], + "source": [ + "print(domain_one_targets)\n", + "print(domain_two_targets)" + ] + }, + { + "cell_type": "code", + "execution_count": 289, + "id": "cb39bed5-10b5-4b00-97d8-70bd3538f406", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "[['D']]\n" + ] + } + ], + "source": [ + "stacked_targets = domain_one_targets.copy()[1:]\n", + "# stacked_targets.extend(domain_two_targets[1:])\n", + "\n", + "print(stacked_targets)" + ] + }, + { + "cell_type": "code", + "execution_count": 290, + "id": "0f6b3ba3-5718-4084-ae95-26db290687cb", + "metadata": {}, + "outputs": [], + "source": [ + "# create context with information about the interventions\n", + "ctx_builder = make_context(create_using=InterventionalContextBuilder)\n", + "ctx: Context = (\n", + " ctx_builder.variables(data=stacked_data[0])\n", + " .num_distributions(len(stacked_data))\n", + " .intervention_targets(stacked_targets)\n", + " .obs_distribution(True)\n", + " .build()\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 291, + "id": "356859c0-5c65-4147-8582-ed027a0e5d5f", + "metadata": {}, + "outputs": [], + "source": [ + "#\n", + "int_learner = PsiFCI(\n", + " ci_estimator=ci_estimator,\n", + " cd_estimator=ci_estimator,\n", + " max_cond_set_size=2,\n", + " n_jobs=-1,\n", + " debug=True,\n", + ")\n", + "\n", + "int_learner = int_learner.learn_graph(stacked_data, ctx)" + ] + }, + { + "cell_type": "code", + "execution_count": 292, + "id": "812f0cd9-0f2e-4571-8e1a-7a365681b09b", + "metadata": {}, + "outputs": [ + { + "data": { + "image/svg+xml": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "W\n", + "\n", + "W\n", + "\n", + "\n", + "\n", + "B\n", + "\n", + "B\n", + "\n", + "\n", + "\n", + "D\n", + "\n", + "D\n", + "\n", + "\n", + "\n", + "B->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "A\n", + "\n", + "A\n", + "\n", + "\n", + "\n", + "B->A\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C\n", + "\n", + "C\n", + "\n", + "\n", + "\n", + "B->C\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X\n", + "\n", + "X\n", + "\n", + "\n", + "\n", + "A->X\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->W\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "C->D\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "X->C\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 292, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "est_pag = int_learner.graph_\n", + "est_pag_no_fnodes = est_pag.subgraph(ctx.get_non_augmented_nodes())\n", + "\n", + "dot_graph = draw(\n", + " est_pag_no_fnodes,\n", + " direction=\"TD\",\n", + ")\n", + "\n", + "dot_graph.render(\n", + " outfile=\"./psifci_multidomain_stacked_obsandint.png\", view=False, cleanup=True\n", + ")\n", + "dot_graph" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8fd85143-ce80-4b8b-9da1-f592e9f59ede", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pywhy-discover", + "language": "python", + "name": "pywhy-discover" + }, + "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.16" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/plot_sfci_with_artificial_sachs.py b/examples/plot_sfci_with_artificial_sachs.py new file mode 100644 index 000000000..982c6c5ec --- /dev/null +++ b/examples/plot_sfci_with_artificial_sachs.py @@ -0,0 +1,203 @@ +from pprint import pprint +import numpy as np +import scipy +import pandas as pd +import collections +from itertools import combinations +import bnlearn +import pooch +from cdt.data import load_dataset + +from pywhy_graphs.functional import ( + make_graph_linear_gaussian, + make_graph_multidomain, + set_node_attributes_with_G, + apply_linear_soft_intervention, + sample_multidomain_lin_functions, +) +from pywhy_graphs.classes import AugmentedGraph +from pywhy_graphs.viz import draw + +from dodiscover.cd import KernelCDTest +from dodiscover.ci import KernelCITest, FisherZCITest, Oracle, GSquareCITest +from dodiscover.constraint.skeleton import LearnMultiDomainSkeleton +from dodiscover.datasets import sample_from_graph + +from dodiscover import PsiFCI, SFCI, Context, make_context, InterventionalContextBuilder + + +def resample_dataset( + G, + df, + prior_multi_dist, + nodes_to_resample, + outcome_values, + n_samples=1000, + seed=12345, +): + rng = np.random.default_rng(seed) + + new_df = np.zeros((n_samples, len(df.columns))) + for idx in range(n_samples): + row_idx = rng.integers(0, len(df)) + + new_df[idx, :] = df.iloc[row_idx, :] + + for jdx, node in enumerate(nodes_to_resample): + prior_dist = prior_multi_dist + col_idx = np.argwhere(df.columns == node).squeeze() + + # sample which index from 1, 2, or 3 it hit + new_sample_idx = rng.multinomial(1, pvals=prior_dist, size=1).squeeze() + new_sample = outcome_values[np.argwhere(new_sample_idx == 1).squeeze()] + new_df[idx, col_idx] = new_sample + + # print("new sample for ", node, new_sample) + # sample the children according to a re-weighted Dirichlet distribution + children = list(G.successors(node)) + for child in children: + child_prior = prior_multi_dist.copy() + child_prior[new_sample_idx] *= new_sample + child_prior = rng.dirichlet(child_prior, 1) + + child_idx = np.argwhere(df.columns == child).squeeze() + + # sample which index from 1, 2, or 3 it hit for children + child_sample_idx = rng.multinomial(1, pvals=child_prior, size=1).squeeze() + child_sample = outcome_values[np.argwhere(child_sample_idx == 1).squeeze()] + new_df[idx, child_idx] = child_sample + # print("New sample for ", child, child_sample) + + new_df = pd.DataFrame(new_df) + new_df.columns = df.columns + return new_df + + +seed = 1234 +n_jobs = -1 +rng = np.random.default_rng(seed) +alpha = 0.05 + +# use pooch to download robustly from a url +url = "https://www.bnlearn.com/book-crc/code/sachs.interventional.txt.gz" +file_path = pooch.retrieve( + url=url, + known_hash="md5:39ee257f7eeb94cb60e6177cf80c9544", +) + +df = pd.read_csv(file_path, delimiter=" ") + +perturbations = [df.columns[perturbed_col] for perturbed_col in df["INT"].unique()] +n_proteins = len(df.columns) - 1 + +print(perturbations) + +# the ground-truth dag is shown here: XXX: comment in when errors are fixed +ground_truth_dag = bnlearn.import_DAG("sachs", verbose=False) +ground_truth_G = ground_truth_dag["model"].to_directed() +G = draw(ground_truth_G, direction="TD", shape="circle") + +# generate now bernoulli probability exogenous per protein +prior_protein_exp = rng.dirichlet(rng.standard_gamma(rng.integers(1, 4), size=3), 1).squeeze() + +outcome_values = np.array([1, 2, 3]) +nodes_to_resample = np.array(["Erk", "PKC", "PIP2"]) + +print(prior_protein_exp) +print(prior_protein_exp.sum(axis=0)) + +new_df = resample_dataset( + ground_truth_G, + df, + prior_multi_dist=prior_protein_exp, + nodes_to_resample=nodes_to_resample, + outcome_values=outcome_values, + n_samples=10000, + seed=12345, +) + +# %% +# Preprocess the dataset +# ---------------------- +# Since the data is one dataframe, we need to process it into a form +# that is acceptable by dodiscover's :class:`constraint.PsiFCI` algorithm. We +# will form a list of separate dataframes. +unique_ints = df["INT"].unique() + +# get the list of intervention targets and list of dataframe associated with each intervention +intervention_targets = [] +data_cols = [col for col in df.columns if col != "INT"] +data = [] +domain_ids = [] +for interv_idx in unique_ints: + _data = df[df["INT"] == interv_idx][data_cols].astype(int) + data.append(_data) + intervention_targets.append(df.columns[interv_idx]) + domain_ids.append(1) + + # append second domain + _data = new_df[new_df["INT"] == interv_idx][data_cols].astype(int) + data.append(_data) + intervention_targets.append(df.columns[interv_idx]) + domain_ids.append(2) + +print(len(data), len(intervention_targets), len(domain_ids)) + +# Setup constraint-based learner +# ------------------------------ +# Since we have access to interventional data, the causal discovery algorithm +# we will use that leverages CI and CD tests to estimate causal constraints +# is the Psi-FCI algorithm :footcite:`Jaber2020causal`. + +# Our dataset is comprised of discrete valued data, so we will utilize the +# G^2 (Chi-square) CI test. +ci_estimator = GSquareCITest(data_type="discrete") + +# Since our data is entirely discrete, we can also use the G^2 test as our +# CD test. +cd_estimator = GSquareCITest(data_type="discrete") + +alpha = 0.05 +learner = SFCI( + ci_estimator=ci_estimator, + cd_estimator=cd_estimator, + alpha=alpha, + max_combinations=10, + max_cond_set_size=4, + n_jobs=-1, +) + +# create context with information about the interventions +ctx_builder = make_context(create_using=InterventionalContextBuilder) +ctx: Context = ctx_builder.variables(data=data[0]).num_distributions(len(data)).build() + +# %% +# Run the learning process +# ------------------------ +# We have setup our causal context and causal discovery learner, so we will now +# run the algorithm using the :meth:`constraint.PsiFCI.fit` API, which is similar to scikit-learn's +# `fit` design. All fitted attributes contain an underscore at the end. +learner = learner.fit( + data, ctx, domain_indices=domain_ids, intervention_targets=intervention_targets +) + +# %% +# Analyze the results +# =================== +# Now that we have learned the graph, we will show it here. Note differences and similarities +# to the ground-truth DAG that is "assumed". Moreover, note that this reproduces Supplementary +# Figure 8 in :footcite:`Jaber2020causal`. +est_pag = learner.graph_ + +print(f"There are {len(est_pag.to_undirected().edges)} edges in the resulting PAG") + +# %% +# Visualize the full graph including the F-node +# dot_graph = draw(est_pag, direction="LR") +# dot_graph.render(outfile="psi_pag_full.png", view=True, cleanup=True) + +# %% +# Visualize the graph without the F-nodes +est_pag_no_fnodes = est_pag.subgraph(ctx.get_non_augmented_nodes()) +dot_graph = draw(est_pag_no_fnodes, direction="LR") +dot_graph.render(outfile="psi_pag.png", view=True, cleanup=True) diff --git a/examples/plot_score_alg.py b/examples/topological/plot_score_alg.py similarity index 100% rename from examples/plot_score_alg.py rename to examples/topological/plot_score_alg.py diff --git a/examples/prior_know_score.py b/examples/topological/prior_know_score.py similarity index 100% rename from examples/prior_know_score.py rename to examples/topological/prior_know_score.py diff --git a/tests/unit_tests/conditional/cd/test_cd.py b/tests/unit_tests/conditional/cd/test_cd.py deleted file mode 100644 index 541aeaa91..000000000 --- a/tests/unit_tests/conditional/cd/test_cd.py +++ /dev/null @@ -1,135 +0,0 @@ -import numpy as np -import pandas as pd -import pytest -from sklearn.ensemble import RandomForestClassifier - -from dodiscover.cd import BregmanCDTest, KernelCDTest - -seed = 12345 - -# number of samples to use in generating test dataset; the lower the faster -n_samples = 160 - - -def single_env_scm(n_samples=200, offset=0.0): - # We construct a SCM where X1 -> Y <- X and Y -> Z - # so X1 is independent from X, but conditionally dependent - # given Y or Z - rng = np.random.default_rng(seed) - - X = rng.standard_normal((n_samples, 1)) + offset - X1 = rng.standard_normal((n_samples, 1)) + offset - Y = X + X1 + 0.1 * rng.standard_normal((n_samples, 1)) - Z = Y + 0.1 * rng.standard_normal((n_samples, 1)) - - # create input for the CD test - df = pd.DataFrame(np.hstack((X, X1, Y, Z)), columns=["x", "x1", "y", "z"]) - - # assign groups randomly - df["group"] = rng.choice([0, 1], size=len(df)) - return df - - -def multi_env_scm(n_samples=100, offset=1.5): - df = single_env_scm(n_samples=n_samples) - df["group"] = 0 - - new_df = single_env_scm(n_samples=n_samples, offset=offset) - new_df["group"] = 1 - df = pd.concat((df, new_df), axis=0) - return df - - -@pytest.mark.parametrize( - "cd_func", - [ - KernelCDTest, - BregmanCDTest, - ], -) -def test_cd_tests_error(cd_func): - x = "x" - y = "y" - - sample_df = single_env_scm(n_samples=10) - cd_estimator = cd_func() - with pytest.raises(ValueError, match="The group col"): - cd_estimator.test(sample_df, y_vars={y}, group_col={"blah"}, x_vars={x}) - - with pytest.raises(ValueError, match="The x variables are not all"): - cd_estimator.test(sample_df, y_vars={y}, group_col={"group"}, x_vars={"blah"}) - - with pytest.raises(ValueError, match="The y variables are not all"): - cd_estimator.test(sample_df, y_vars={"blah"}, group_col={"group"}, x_vars={x}) - - with pytest.raises(ValueError, match="Group column should be only one column"): - cd_estimator.test(sample_df, y_vars={"blah"}, group_col="group", x_vars={x}) - - # all the group indicators have different values now from 0/1 - sample_df["group"] = sample_df["group"] + 3 - with pytest.raises(RuntimeError, match="Group indications in"): - cd_estimator.test(sample_df, y_vars={y}, group_col={"group"}, x_vars={x}) - - # test pre-fit propensity scores, or custom propensity model - with pytest.raises( - ValueError, match="Both propensity model and propensity estimates are specified" - ): - cd_estimator = cd_func(propensity_model=RandomForestClassifier(), propensity_est=[0.5, 0.5]) - cd_estimator.test(sample_df, y_vars={y}, group_col={"group"}, x_vars={x}) - - with pytest.raises(ValueError, match="There are 3 group pre-defined estimates"): - cd_estimator = cd_func(propensity_est=np.ones((10, 3)) * 0.5) - cd_estimator.test(sample_df, y_vars={y}, group_col={"group"}, x_vars={x}) - - with pytest.raises(ValueError, match="There are 100 pre-defined estimates"): - cd_estimator = cd_func(propensity_est=np.ones((100, 2)) * 0.5) - cd_estimator.test(sample_df, y_vars={y}, group_col={"group"}, x_vars={x}) - - -@pytest.mark.parametrize( - ["cd_func", "cd_kwargs"], - [ - [BregmanCDTest, dict()], - [KernelCDTest, dict()], - [BregmanCDTest, {"propensity_model": RandomForestClassifier()}], - [BregmanCDTest, {"propensity_est": np.ones((n_samples, 2)) * 0.5}], - [KernelCDTest, {"propensity_model": RandomForestClassifier()}], - [KernelCDTest, {"propensity_est": np.ones((n_samples, 2)) * 0.5}], - [KernelCDTest, {"l2": 1e-3}], - [KernelCDTest, {"l2": (1e-3, 2e-3)}], - ], -) -@pytest.mark.parametrize( - ["df", "env_type"], - [ - [single_env_scm(n_samples=n_samples, offset=2.0), "single"], - [multi_env_scm(n_samples=n_samples // 2, offset=2.0), "multi"], - ], -) -def test_cd_simulation(cd_func, df, env_type, cd_kwargs): - """Test conditional discrepancy tests.""" - random_state = 12345 - cd_estimator = cd_func(random_state=random_state, null_reps=15, n_jobs=-1, **cd_kwargs) - - group_col = "group" - alpha = 0.1 - - if env_type == "single": - _, pvalue = cd_estimator.test( - df, - y_vars={"x1"}, - group_col={group_col}, - x_vars={"x"}, - ) - assert pvalue > alpha, f"Fails with {pvalue} not greater than {alpha}" - _, pvalue = cd_estimator.test(df, y_vars={"z"}, group_col={group_col}, x_vars={"x"}) - assert pvalue > alpha, f"Fails with {pvalue} not greater than {alpha}" - _, pvalue = cd_estimator.test(df, y_vars={"y"}, group_col={group_col}, x_vars={"x"}) - assert pvalue > alpha, f"Fails with {pvalue} not greater than {alpha}" - elif env_type == "multi": - _, pvalue = cd_estimator.test(df, y_vars={"z"}, group_col={group_col}, x_vars={"x"}) - assert pvalue < alpha, f"Fails with {pvalue} not less than {alpha}" - _, pvalue = cd_estimator.test(df, y_vars={"y"}, group_col={group_col}, x_vars={"x"}) - assert pvalue < alpha, f"Fails with {pvalue} not less than {alpha}" - _, pvalue = cd_estimator.test(df, y_vars={"z"}, group_col={group_col}, x_vars={"x1"}) - assert pvalue < alpha, f"Fails with {pvalue} not less than {alpha}" diff --git a/tests/unit_tests/constraint/skeleton/test_multidomain_skeleton.py b/tests/unit_tests/constraint/skeleton/test_multidomain_skeleton.py new file mode 100644 index 000000000..91ff49381 --- /dev/null +++ b/tests/unit_tests/constraint/skeleton/test_multidomain_skeleton.py @@ -0,0 +1,308 @@ +import math + +import networkx as nx +import numpy as np +import pywhy_graphs as pgraphs +from pywhy_graphs.functional import sample_from_graph + +from dodiscover import ContextBuilder, InterventionalContextBuilder, make_context +from dodiscover.cd import KernelCDTest +from dodiscover.ci import FisherZCITest, Oracle +from dodiscover.constraint.skeleton import LearnMultiDomainSkeleton +from dodiscover.constraint.utils import dummy_sample + + +def basic_multidomain_augmented_graph(): + # Create the following graph: + # F_x -> x -> y -> z + # S_{1,2} -> y + # x <--> y + directed_edges = [ + ("x", "y"), + ("y", "z"), + ] + bidirected_edges = [("x", "y")] + graph = pgraphs.AugmentedGraph( + incoming_directed_edges=directed_edges, incoming_bidirected_edges=bidirected_edges + ) + graph.add_f_node({"x"}) + graph.add_f_node({"x"}, require_unique=False) + graph.add_s_node((1, 2), {"y"}) + + return graph + + +def test_fnode_multidomain_skeleton_known_targets(): + """Test learning the skeleton for Figure 3 in :footcite:`Kocaoglu2019characterization`.""" + # first create the oracle + directed_edges = [ + ("x", "w"), + ("w", "y"), + ("z", "y"), + ] + bidirected_edges = [("x", "z"), ("z", "y")] + graph = pgraphs.AugmentedGraph( + incoming_directed_edges=directed_edges, incoming_bidirected_edges=bidirected_edges + ) + non_f_graph = graph.copy() + graph.add_f_node({"x"}) + oracle = Oracle(graph) + + # define the expected graph we will learn + edges = [ + (("F", 0), "x"), + (("F", 0), "y"), + ("x", "w"), + ("x", "z"), + ("x", "y"), + ("z", "y"), + ("w", "y"), + ] + expected_skeleton = nx.Graph(edges) + obs_expected_skeleton = expected_skeleton.copy() + obs_expected_skeleton.remove_node(("F", 0)) + + # define the learner and the context + learner = LearnMultiDomainSkeleton( + ci_estimator=oracle, cd_estimator=oracle, known_intervention_targets=True + ) + data = [dummy_sample(non_f_graph), dummy_sample(non_f_graph)] + context = ( + make_context(create_using=InterventionalContextBuilder).variables(data=data[0]).build() + ) + domain_indices = [1, 1] + intervention_targets = [{}, {"x"}] + learner.learn_graph( + data, context, domain_indices=domain_indices, intervention_targets=intervention_targets + ) + + # first check the observational skeleton + skel_graph = learner.adj_graph_ + obs_skel_graph = learner.context_.state_variable("obs_skel_graph").subgraph( + context.get_non_augmented_nodes() + ) + for edge in skel_graph.edges(): + if not expected_skeleton.has_edge(*edge): + print("extra edge: ", edge) + for edge in expected_skeleton.edges(): + if not skel_graph.has_edge(*edge): + print("missing edge: ", edge) + assert nx.is_isomorphic(obs_expected_skeleton, obs_skel_graph, edge_match=None) + assert nx.is_isomorphic(expected_skeleton, skel_graph) + + +def test_fnode_multidomain_skeleton_known_targets_with_snode(): + """Test learning the skeleton for Figure 3 in :footcite:`Kocaoglu2019characterization`. + + However, this time, we have an S-node pointing to y. + """ + # first create the oracle + directed_edges = [ + ("x", "w"), + ("w", "y"), + ("z", "y"), + ] + bidirected_edges = [("x", "z"), ("z", "y")] + graph = pgraphs.AugmentedGraph( + incoming_directed_edges=directed_edges, incoming_bidirected_edges=bidirected_edges + ) + non_f_graph = graph.copy() + graph.add_f_node({"x"}, domain={1, 2}, require_unique=False) + graph.add_f_node({"x"}, domain={2}, require_unique=False) + graph.add_s_node((1, 2), {"y"}) + oracle = Oracle(graph) + + # define the expected graph we will learn + edges = [ + (("F", 0), "x"), + (("F", 0), "y"), + (("F", 1), "x"), + (("F", 1), "y"), + (("F", 2), "y"), + ("x", "w"), + ("x", "z"), + ("x", "y"), + ("z", "y"), + ("w", "y"), + ] + expected_skeleton = nx.Graph(edges) + obs_expected_skeleton = expected_skeleton.copy() + obs_expected_skeleton.remove_nodes_from(graph.augmented_nodes) + + # define the learner and the context + learner = LearnMultiDomainSkeleton( + ci_estimator=oracle, cd_estimator=oracle, known_intervention_targets=True, n_jobs=1 + ) + data = [dummy_sample(non_f_graph), dummy_sample(non_f_graph), dummy_sample(non_f_graph)] + context = ( + make_context(create_using=InterventionalContextBuilder).variables(data=data[0]).build() + ) + domain_indices = [1, 2, 2] + intervention_targets = [{}, {}, {"x"}] + + learner.learn_graph( + data, context, domain_indices=domain_indices, intervention_targets=intervention_targets + ) + + # first check the observational skeleton + skel_graph = learner.adj_graph_ + obs_skel_graph = learner.context_.state_variable("obs_skel_graph").subgraph( + context.get_non_augmented_nodes() + ) + for edge in skel_graph.edges(): + if not expected_skeleton.has_edge(*edge): + print("extra edge: ", edge) + for edge in expected_skeleton.edges(): + if not skel_graph.has_edge(*edge): + print("missing edge: ", edge) + assert nx.is_isomorphic(obs_expected_skeleton, obs_skel_graph, edge_match=None) + assert nx.is_isomorphic(expected_skeleton, skel_graph) + + +def test_basic_multidomain_fsnode_skeleton(): + """Test basic skeleton learning with a multidomain f-node and s-node.""" + graph = basic_multidomain_augmented_graph() + non_f_graph = graph.subgraph(graph.non_augmented_nodes) + + oracle = Oracle(graph, graph.augmented_nodes) + + # define the expected graph we will learn + edges = [ + (("F", 0), "x"), + (("F", 0), "y"), + (("F", 1), "x"), + (("F", 1), "y"), + (("F", 2), "y"), # correspondence with the S-node + ("x", "y"), + ("y", "z"), + ] + expected_skeleton = nx.Graph(edges) + obs_expected_skeleton = expected_skeleton.copy() + + # define the learner and the context + learner = LearnMultiDomainSkeleton(ci_estimator=oracle, cd_estimator=oracle) + data = [dummy_sample(non_f_graph), dummy_sample(non_f_graph), dummy_sample(non_f_graph)] + domain_indices = [1, 1, 2] + intervention_targets = [set(), {"x"}, set()] + + context = ( + make_context(create_using=ContextBuilder).variables(data=data[0]) + # .num_distributions(2) + # .intervention_targets([("x")]) + .build() + ) + learner.learn_graph(data, context, domain_indices, intervention_targets) + + # first check the observational skeleton + skel_graph = learner.adj_graph_ + obs_skel_graph = learner.context_.state_variable("obs_skel_graph").subgraph( + context.observed_variables + ) + obs_expected_skeleton = obs_expected_skeleton.subgraph(context.observed_variables) + sep_set = learner.sep_set_ + + # check the separating sets + # XXX: the edge is tested twice + assert sep_set["x"]["z"] == [ + {"y", ("F", 0), ("F", 2), ("F", 1)}, + {"y", ("F", 0), ("F", 2), ("F", 1)}, + ] + + # check the skeleton after obs data + print(obs_expected_skeleton.edges()) + print(obs_skel_graph.edges()) + assert nx.is_isomorphic(obs_expected_skeleton, obs_skel_graph, edge_match=None) + + # check the skeleton after intervention + print(skel_graph.edges()) + print(expected_skeleton.edges()) + assert nx.is_isomorphic(expected_skeleton, skel_graph) + + +# import pytest +# @pytest.mark.skip() +def test_basic_multidomain_fsnode_skeleton_with_lindata(): + seed = 1234 + n_samples = 1000 + aug_graph = basic_multidomain_augmented_graph() + graph = aug_graph.subgraph(aug_graph.non_augmented_nodes) + + # define the expected graph we will learn + edges = [ + (("F", 0), "x"), + (("F", 0), "y"), + (("F", 1), "x"), + (("F", 1), "y"), + (("S", 0), "y"), + ("x", "y"), + ("y", "z"), + ] + expected_skeleton = nx.Graph(edges) + obs_expected_skeleton = expected_skeleton.copy() + + # define functional relationships of the causal diagram + graph = pgraphs.functional.make_random_linear_gaussian_graph(graph, random_state=seed) + + datasets = [] + domain_ids = [] + intervention_sets = [] + + # now for each F-node, apply a linear additive intervention + for f_node, fnode_data in aug_graph.graph["F-nodes"].items(): + targets = fnode_data["targets"] + domains = fnode_data["domains"] + new_graph = pgraphs.functional.apply_linear_soft_intervention( + graph.copy(), targets, random_state=seed + ) + + print(new_graph.nodes(data=True)) + # generate dataset + data = sample_from_graph(new_graph, n_samples=n_samples, random_state=seed) + + datasets.append(data) + intervention_sets.append(targets) + domain_ids.append(1) + + print("Targets are: ", targets) + + # now for each S-node, apply a linear additive intervention + # for s_node, domains in aug_graph.graph["S-nodes"].items(): + # s_node_targets = list(aug_graph.children(s_node)) + # print(list(s_node_targets)) + # print(domains) + # new_graph = pgraphs.functional.apply_linear_soft_intervention( + # graph.copy(), s_node_targets, random_state=seed + # ) + + # # generate dataset + # data = sample_from_graph(new_graph, n_samples=n_samples, random_state=seed) + + # datasets.append(data) + # intervention_sets.append(s_node_targets) + # domain_ids.append(domains) + + learner = LearnMultiDomainSkeleton(ci_estimator=FisherZCITest(), cd_estimator=KernelCDTest()) + + context = make_context(create_using=ContextBuilder).variables(data=datasets[0]).build() + learner.learn_graph(datasets, context, domain_ids, intervention_sets) + + # first check the observational skeleton + skel_graph = learner.adj_graph_ + obs_skel_graph = learner.context_.state_variable("obs_skel_graph").subgraph( + context.observed_variables + ) + obs_expected_skeleton = obs_expected_skeleton.subgraph(context.observed_variables) + sep_set = learner.sep_set_ + + # check the separating sets + assert sep_set["x"]["z"] == [{"y", ("F", 0), ("S", 0), ("F", 1)}] + + # check the skeleton after obs data + print(obs_expected_skeleton.edges()) + print(obs_skel_graph.edges()) + assert nx.is_isomorphic(obs_expected_skeleton, obs_skel_graph, edge_match=None) + + # check the skeleton after intervention + print(skel_graph.edges()) + print(expected_skeleton.edges()) + assert nx.is_isomorphic(expected_skeleton, skel_graph)