Repository: pgmpy/pgmpy

Issue:
More ways to specify BayesianNetwork (from DAG?)
Just as DAG can be specified in multiple ways https://github.com/pgmpy/pgmpy/issues/1769 ,
we could also let BayesianNetwork to be specified from DAG. 

Discussion:

Patch:
diff --git a/examples/How to define CPDs.ipynb b/examples/How to define CPDs.ipynb
new file mode 100644
index 00000000..023627ee
--- /dev/null
+++ b/examples/How to define CPDs.ipynb
@@ -0,0 +1,318 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# How to define TabularCPD and LinearGaussianCPD\n",
+    "\n",
+    "One can implement `TabularCPD` for discrete variables inside `BayesianNetwork`.\n",
+    "and `LinearGaussianCPD` for continuous variables inside `LinearGaussianBayesianNetwork`. \n",
+    "\n",
+    "In this tutorial, we will demonstrate how to define each CPD.\n",
+    "\n",
+    "## TabularCPD for discrete variables\n",
+    "\n",
+    "In tabular CPD, the probability for discrete variable is given as a table. Let us start with examples for independent variables."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from pgmpy.factors.discrete import TabularCPD\n",
+    "\n",
+    "cpd_coin_fair = TabularCPD(variable=\"coin\", variable_card=2, values=[[0.5], [0.5]], state_names={'Coin': ['Head', 'Tail']})\n",
+    "cpd_coin_biased = TabularCPD(variable=\"coin\", variable_card=2, values=[[0.9], [0.1]], state_names={'Coin': ['Head', 'Tail']})"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "For a coin flip, we have discrete number (2) possible outcomes, therefore `variable_card=2` is passed. `values` pass probabilities for each outcome, which sum up to 1. `state_names` is optional, it gives names for each outcome."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "cpd_smoke = TabularCPD(variable=\"Smoker\", variable_card=2, values=[[0.3], [0.7]], state_names={'Smoker': ['Non-smoker', 'Smoker']})\n",
+    "cpd_pollution = TabularCPD(variable=\"Pollution\", variable_card=3, values=[[0.7], [0.29], [0.01]], state_names={'Pollution': ['Clean', 'Bad', 'Fatal']})"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The `Pollution` and `Smoker` variables do not depend on other variables. They take categorical values. `variable_card` denotes how many categories the variable can take. For example `Smoker` can take a binary value since `variable_card=2`. `values` is an array of probability values for each category. Note that the probability `values` sum up to 1.\n",
+    "\n",
+    "### Tabular CPD with multiple evidence.\n",
+    "\n",
+    "We next consider another discrete variable `Cancer`, with a model assumption that it depends on `Pollution` and `Smoker`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "cpd_cancer = TabularCPD(\n",
+    "    variable=\"Cancer\",\n",
+    "    variable_card=2,\n",
+    "    values=[[0.20, 0.15, 0.03, 0.05, 0.001, 0.02],\n",
+    "            [0.80, 0.85, 0.97, 0.95, 0.999, 0.98]],\n",
+    "    evidence=[\"Smoker\", \"Pollution\"],\n",
+    "    evidence_card=[2, 3],\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "For `Cancer` variable, we pass another `TabularCPD`. `evidence_card` denotes cardinality of each evidence variable. There are total `2*3=6` different combinations of evidence. Note the values of `evidence` affects probability of `Cancer`. We have 6 columns in `values` denoting conditional probabilities. `values` sums up to 1 columnwise. The columns are partitioned by the first evidence first. The first three columns correspond to non-smokers.\n",
+    "\n",
+    "We next consider another discrete variable `D`, with a model assumption that it depends on `A`, `B` and `C`."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "cpd = TabularCPD(\n",
+    "    variable=\"D\",\n",
+    "    variable_card=2,\n",
+    "    values=[[0.20, 0.15, 0.93, 0.05, 0.001, 0.02, 0.10, 0.25 ],\n",
+    "            [0.80, 0.85, 0.07, 0.95, 0.999, 0.98, 0.90, 0.75 ]],\n",
+    "    evidence=[\"A\", \"B\", \"C\"],\n",
+    "    evidence_card=[2, 2, 2],\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "+------+------+------+------+------+-------+------+------+------+\n",
+      "| A    | A(0) | A(0) | A(0) | A(0) | A(1)  | A(1) | A(1) | A(1) |\n",
+      "+------+------+------+------+------+-------+------+------+------+\n",
+      "| B    | B(0) | B(0) | B(1) | B(1) | B(0)  | B(0) | B(1) | B(1) |\n",
+      "+------+------+------+------+------+-------+------+------+------+\n",
+      "| C    | C(0) | C(1) | C(0) | C(1) | C(0)  | C(1) | C(0) | C(1) |\n",
+      "+------+------+------+------+------+-------+------+------+------+\n",
+      "| D(0) | 0.2  | 0.15 | 0.93 | 0.05 | 0.001 | 0.02 | 0.1  | 0.25 |\n",
+      "+------+------+------+------+------+-------+------+------+------+\n",
+      "| D(1) | 0.8  | 0.85 | 0.07 | 0.95 | 0.999 | 0.98 | 0.9  | 0.75 |\n",
+      "+------+------+------+------+------+-------+------+------+------+\n"
+     ]
+    }
+   ],
+   "source": [
+    "print(cpd)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Each column of `values` correspond to a different combinations of `evidence` variables. If the evidence variables were lexicographic as above `(A, B, C)`, then the columns will be also lexicographic. \n",
+    "\n",
+    "```(A(0)B(0)C(0), A(0)B(0)C(1), A(0)B(1)C(0), A(0)B(1)C(1), ...)```\n",
+    "\n",
+    "It is as though we loop over A, and B, and C, in a nested order. \n",
+    "\n",
+    "### CPD with random values\n",
+    "It is also possible to create a random tabular CPD."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([[0.55203474],\n",
+       "       [0.44796526]])"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "cpd_coin_random = TabularCPD.get_random(variable=\"coin\", cardinality = {'coin':2}, state_names={'Coin': ['Head', 'Tail']})\n",
+    "cpd_coin_random.get_values()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Note the probabilities can be retrieved by `get_values` and they add up to 1 again. If `cardinality` is missing, the discrete variable is assumed to be binary. \n",
+    "\n",
+    "When there are `evidence`, the cardinality of evidence needs to be also specified."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([[0.30404579, 0.99344749, 0.47933983, 0.58564998, 0.54269003,\n",
+       "        0.28164646],\n",
+       "       [0.69595421, 0.00655251, 0.52066017, 0.41435002, 0.45730997,\n",
+       "        0.71835354]])"
+      ]
+     },
+     "execution_count": 7,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "cpd_cancer_random = TabularCPD.get_random(\n",
+    "    variable=\"Cancer\",\n",
+    "    cardinality = {\"Cancer\": 2, \"Smoker\": 2, \"Pollution\": 3},\n",
+    "    evidence=[\"Smoker\", \"Pollution\"],\n",
+    ")\n",
+    "cpd_cancer_random.get_values()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([1., 1., 1., 1., 1., 1.])"
+      ]
+     },
+     "execution_count": 8,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "cpd_cancer_random.get_values().sum(axis = 0)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Note again the probability table sums up to 1 for each column.\n",
+    "\n",
+    "## LinearGaussianCPD for continuous variables\n",
+    "\n",
+    "We define CPDs for each variable. `LinearGaussianCPD` assumes that each variable takes normal distribution. \n",
+    "Assume `Healthy` and `Wealthy` variables have no parents (empty evidence). "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "<LinearGaussianCPD: P(Healthy) = N(4; 2) at 0x15735ed20\n",
+      "<LinearGaussianCPD: P(Wealthy) = N(2; 3) at 0x1566c2930\n"
+     ]
+    }
+   ],
+   "source": [
+    "# Step 2: Define the CPDs.\n",
+    "from pgmpy.factors.continuous import LinearGaussianCPD\n",
+    "cpd_healthy = LinearGaussianCPD(variable=\"Healthy\", beta=[4], std=2, evidence=[])\n",
+    "cpd_wealthy = LinearGaussianCPD(variable=\"Wealthy\", beta=[2], std=3, evidence=[])\n",
+    "\n",
+    "import pprint\n",
+    "pprint.pp(cpd_healthy)\n",
+    "pprint.pp(cpd_wealthy)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Above we defined, Healthy as normal distribution with mean 4 and standard deviation 2, and wealthy with mean 2, and standard deviation 3. \n",
+    "(Assume bigger variation in people's wealth than health, bigger mean for health than wealth)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "<LinearGaussianCPD: P(Happy | Healthy, Wealthy) = N(3*Healthy + 2*Wealthy + 1; 5) at 0x154160050\n"
+     ]
+    }
+   ],
+   "source": [
+    "cpd_happy = LinearGaussianCPD(\n",
+    "    variable=\"Happy\",\n",
+    "    beta=[1, 3, 2],\n",
+    "    std=5,\n",
+    "    evidence=[\"Healthy\", \"Wealthy\"],\n",
+    ")\n",
+    "pprint.pp(cpd_happy)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The `Happy` variable has mean `3*Healthy + 2*Wealthy + 1`, this formula is determined by passing `beta` and `evidence` variables. Note that the first element of `beta` is the intercept (constant term). The rest of the elements in `beta` each match the evidence.\n",
+    "The standard deviation of normal distribution of `Happy` is set by `std`."
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "pgmpy",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.12.8"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/pgmpy/models/BayesianNetwork.py b/pgmpy/models/BayesianNetwork.py
index d1ae7b42..fee4e2b5 100644
--- a/pgmpy/models/BayesianNetwork.py
+++ b/pgmpy/models/BayesianNetwork.py
@@ -91,12 +91,17 @@ class BayesianNetwork(DAG):
     3
     """
 
-    def __init__(self, ebunch=None, latents=set()):
-        super(BayesianNetwork, self).__init__(ebunch=ebunch, latents=latents)
+    def __init__(self, ebunch=None, latents=set(), lavaan_str=None, dagitty_str=None):
+        super(BayesianNetwork, self).__init__(
+            ebunch=ebunch,
+            latents=latents,
+            lavaan_str=lavaan_str,
+            dagitty_str=dagitty_str,
+        )
         self.cpds = []
         self.cardinalities = defaultdict(int)
 
-    def add_edge(self, u, v, **kwargs):
+    def add_edge(self, u, v, w=None, **kwargs):
         """
         Add an edge between u and v.
 
@@ -123,7 +128,10 @@ class BayesianNetwork(DAG):
                 % (u, v)
             )
         else:
-            super(BayesianNetwork, self).add_edge(u, v, **kwargs)
+            if w:
+                super(BayesianNetwork, self).add_edge(u, v, w, **kwargs)
+            else:
+                super(BayesianNetwork, self).add_edge(u, v, **kwargs)
 
     def remove_node(self, node):
         """
diff --git a/pgmpy/models/LinearGaussianBayesianNetwork.py b/pgmpy/models/LinearGaussianBayesianNetwork.py
index 483297cd..3b265d7b 100644
--- a/pgmpy/models/LinearGaussianBayesianNetwork.py
+++ b/pgmpy/models/LinearGaussianBayesianNetwork.py
@@ -21,9 +21,12 @@ class LinearGaussianBayesianNetwork(DAG):
 
     """
 
-    def __init__(self, ebunch=None, latents=set()):
+    def __init__(self, ebunch=None, latents=set(), lavaan_str=None, dagitty_str=None):
         super(LinearGaussianBayesianNetwork, self).__init__(
-            ebunch=ebunch, latents=latents
+            ebunch=ebunch,
+            latents=latents,
+            lavaan_str=lavaan_str,
+            dagitty_str=dagitty_str,
         )
         self.cpds = []
 


Test patch:
diff --git a/pgmpy/tests/test_models/test_BayesianNetwork.py b/pgmpy/tests/test_models/test_BayesianNetwork.py
index 4a5b96d8..8fc9dd69 100644
--- a/pgmpy/tests/test_models/test_BayesianNetwork.py
+++ b/pgmpy/tests/test_models/test_BayesianNetwork.py
@@ -42,6 +42,26 @@ class TestBaseModelCreation(unittest.TestCase):
     def test_class_init_with_data_nonstring(self):
         BayesianNetwork([(1, 2), (2, 3)])
 
+    def test_class_init_with_adj_matrix_dict_of_dict(self):
+        adj = {"a": {"b": 4, "c": 3}, "b": {"c": 2}}
+        self.graph = BayesianNetwork(adj, latents=set(["a"]))
+        self.assertEqual(self.graph.latents, set("a"))
+        self.assertListEqual(sorted(self.graph.nodes()), ["a", "b", "c"])
+        self.assertEqual(self.graph.adj["a"]["c"]["weight"], 3)
+
+    def test_class_init_with_adj_matrix_dict_of_list(self):
+        adj = {"a": ["b", "c"], "b": ["c"]}
+        self.graph = BayesianNetwork(adj, latents=set(["a"]))
+        self.assertEqual(self.graph.latents, set("a"))
+        self.assertListEqual(sorted(self.graph.nodes()), ["a", "b", "c"])
+
+    def test_class_init_with_pd_adj_df(self):
+        df = pd.DataFrame([[0, 3], [0, 0]])
+        self.graph = BayesianNetwork(df, latents=set([0]))
+        self.assertEqual(self.graph.latents, set([0]))
+        self.assertListEqual(sorted(self.graph.nodes()), [0, 1])
+        self.assertEqual(self.graph.adj[0][1]["weight"], {"weight": 3})
+
     def test_add_node_string(self):
         self.G.add_node("a")
         self.assertListEqual(list(self.G.nodes()), ["a"])
@@ -118,6 +138,23 @@ class TestBaseModelCreation(unittest.TestCase):
         del self.G
 
 
+class TestBayesianNetworkParser(unittest.TestCase):
+    def test_from_lavaan(self):
+        model_str = "i =~ x1 + x2 + x3"
+        model_from_str = BayesianNetwork.from_lavaan(string=model_str)
+        expected_edges = set([("i", "x1"), ("i", "x2"), ("i", "x3")])
+        expected_latents = set(["i"])
+        self.assertEqual(set(model_from_str.edges()), expected_edges)
+        self.assertEqual(set(model_from_str.latents), expected_latents)
+
+    def test_from_daggitty(self):
+        dag = BayesianNetwork.from_dagitty(
+            'dag{ bb="0,0,1,1" X [l, pos="-1.228,-1.145"] X-> {Y Z}  Z->A}'
+        )
+        self.assertEqual(set(dag.edges()), set([("X", "Z"), ("X", "Y"), ("Z", "A")]))
+        self.assertEqual(set(dag.latents), set(["X"]))
+
+
 class TestBayesianNetworkMethods(unittest.TestCase):
     def setUp(self):
         self.G = BayesianNetwork([("a", "d"), ("b", "d"), ("d", "e"), ("b", "c")])
diff --git a/pgmpy/tests/test_models/test_FunctionalBayesianNetwork.py b/pgmpy/tests/test_models/test_FunctionalBayesianNetwork.py
index 020cc57a..a60ba5f6 100644
--- a/pgmpy/tests/test_models/test_FunctionalBayesianNetwork.py
+++ b/pgmpy/tests/test_models/test_FunctionalBayesianNetwork.py
@@ -161,3 +161,39 @@ class TestFBNMethods(unittest.TestCase):
 
         self.assertTrue(np.all(samples["lognormal"] > 0))
         self.assertTrue(np.all(samples["gamma"] > 0))
+
+
+class TestFBNCreation(unittest.TestCase):
+    def test_class_init_with_adj_matrix_dict_of_dict(self):
+        adj = {"a": {"b": 4, "c": 3}, "b": {"c": 2}}
+        self.graph = FunctionalBayesianNetwork(adj, latents=set(["a"]))
+        self.assertEqual(self.graph.latents, set("a"))
+        self.assertListEqual(sorted(self.graph.nodes()), ["a", "b", "c"])
+        self.assertEqual(self.graph.adj["a"]["c"]["weight"], 3)
+
+    def test_class_init_with_adj_matrix_dict_of_list(self):
+        adj = {"a": ["b", "c"], "b": ["c"]}
+        self.graph = FunctionalBayesianNetwork(adj, latents=set(["a"]))
+        self.assertEqual(self.graph.latents, set("a"))
+        self.assertListEqual(sorted(self.graph.nodes()), ["a", "b", "c"])
+
+    def test_class_init_with_pd_adj_df(self):
+        df = pd.DataFrame([[0, 3], [0, 0]])
+        self.graph = FunctionalBayesianNetwork(df, latents=set([0]))
+        self.assertEqual(self.graph.latents, set([0]))
+        self.assertListEqual(sorted(self.graph.nodes()), [0, 1])
+        self.assertEqual(self.graph.adj[0][1]["weight"], {"weight": 3})
+
+
+class TestDAGParser(unittest.TestCase):
+    def test_from_lavaan(self):
+        model_str = "ind60 =~ x1"
+        model_from_str = FunctionalBayesianNetwork.from_lavaan(string=model_str)
+        expected_edges = set([("ind60", "x1")])
+        self.assertEqual(set(model_from_str.edges()), expected_edges)
+
+    def test_from_dagitty(self):
+        model_str = """dag{X -> Y}"""
+        model_from_str = FunctionalBayesianNetwork.from_dagitty(string=model_str)
+        expected_edges = set([("X", "Y")])
+        self.assertEqual(set(model_from_str.edges()), expected_edges)
diff --git a/pgmpy/tests/test_models/test_LinearGaussianBayesianNetwork.py b/pgmpy/tests/test_models/test_LinearGaussianBayesianNetwork.py
index ea2aacf1..a1a62ad5 100644
--- a/pgmpy/tests/test_models/test_LinearGaussianBayesianNetwork.py
+++ b/pgmpy/tests/test_models/test_LinearGaussianBayesianNetwork.py
@@ -223,3 +223,39 @@ class TestLGBNMethods(unittest.TestCase):
 
     def tearDown(self):
         del self.model, self.cpd1, self.cpd2, self.cpd3
+
+
+class TestLGBNCreation(unittest.TestCase):
+    def test_class_init_with_adj_matrix_dict_of_dict(self):
+        adj = {"a": {"b": 4, "c": 3}, "b": {"c": 2}}
+        self.graph = LinearGaussianBayesianNetwork(adj, latents=set(["a"]))
+        self.assertEqual(self.graph.latents, set("a"))
+        self.assertListEqual(sorted(self.graph.nodes()), ["a", "b", "c"])
+        self.assertEqual(self.graph.adj["a"]["c"]["weight"], 3)
+
+    def test_class_init_with_adj_matrix_dict_of_list(self):
+        adj = {"a": ["b", "c"], "b": ["c"]}
+        self.graph = LinearGaussianBayesianNetwork(adj, latents=set(["a"]))
+        self.assertEqual(self.graph.latents, set("a"))
+        self.assertListEqual(sorted(self.graph.nodes()), ["a", "b", "c"])
+
+    def test_class_init_with_pd_adj_df(self):
+        df = pd.DataFrame([[0, 3], [0, 0]])
+        self.graph = LinearGaussianBayesianNetwork(df, latents=set([0]))
+        self.assertEqual(self.graph.latents, set([0]))
+        self.assertListEqual(sorted(self.graph.nodes()), [0, 1])
+        self.assertEqual(self.graph.adj[0][1]["weight"], {"weight": 3})
+
+
+class TestDAGParser(unittest.TestCase):
+    def test_from_lavaan(self):
+        model_str = "d ~ i"
+        model_from_str = LinearGaussianBayesianNetwork.from_lavaan(string=model_str)
+        expected_edges = set([("i", "d")])
+        self.assertEqual(set(model_from_str.edges()), expected_edges)
+
+    def test_from_dagitty(self):
+        model_str = """dag{ smoking -> "carry matches" }"""
+        model_from_str = LinearGaussianBayesianNetwork.from_dagitty(string=model_str)
+        expected_edges = set([("smoking", "carry matches")])
+        self.assertEqual(set(model_from_str.edges()), expected_edges)