Repository: pgmpy/pgmpy

Issue:
Add ways to specify DAGs using simpler notations
Such as with dagitty syntax: https://www.dagitty.net/manual-3.x.pdf#page=3.58 , lavaan syntax: https://lavaan.ugent.be/tutorial/syntax1.html, adjacency matrix 

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/pgmpy/pgmpy/pull/1909?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pgmpy) Report
Attention: Patch coverage is `92.53731%` with `5 lines` in your changes missing coverage. Please review.
> Project coverage is 36.42%. Comparing base [(`2b1a088`)](https://app.codecov.io/gh/pgmpy/pgmpy/commit/2b1a08806d241b90a9ecbc0ad94920818885135e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pgmpy) to head [(`b788bdf`)](https://app.codecov.io/gh/pgmpy/pgmpy/commit/b788bdf849d04f7936b80429068c60503ceb86ff?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pgmpy).
> Report is 3 commits behind head on dev.

| [Files with missing lines](https://app.codecov.io/gh/pgmpy/pgmpy/pull/1909?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pgmpy) | Patch % | Lines |
|---|---|---|
| [pgmpy/utils/parser.py](https://app.codecov.io/gh/pgmpy/pgmpy/pull/1909?src=pr&el=tree&filepath=pgmpy%2Futils%2Fparser.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pgmpy#diff-cGdtcHkvdXRpbHMvcGFyc2VyLnB5) | 90.62% | [3 Missing :warning: ](https://app.codecov.io/gh/pgmpy/pgmpy/pull/1909?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pgmpy) |
| [pgmpy/base/DAG.py](https://app.codecov.io/gh/pgmpy/pgmpy/pull/1909?src=pr&el=tree&filepath=pgmpy%2Fbase%2FDAG.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pgmpy#diff-cGdtcHkvYmFzZS9EQUcucHk=) | 92.30% | [1 Missing :warning: ](https://app.codecov.io/gh/pgmpy/pgmpy/pull/1909?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pgmpy) |
| [pgmpy/models/SEM.py](https://app.codecov.io/gh/pgmpy/pgmpy/pull/1909?src=pr&el=tree&filepath=pgmpy%2Fmodels%2FSEM.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pgmpy#diff-cGdtcHkvbW9kZWxzL1NFTS5weQ==) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/pgmpy/pgmpy/pull/1909?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pgmpy) |

> :exclamation:  There is a different number of reports uploaded between BASE (2b1a088) and HEAD (b788bdf). Click for more details.
> 
> <details><summary>HEAD has 4 uploads less than BASE</summary>
>
>| Flag | BASE (2b1a088) | HEAD (b788bdf) |
>|------|------|------|
>||5|1|
></details>

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##              dev    #1909       +/-   ##
===========================================
- Coverage   92.80%   36.42%   -56.39%     
===========================================
  Files         143      144        +1     
  Lines       18457    18464        +7     
===========================================
- Hits        17129     6725    -10404     
- Misses       1328    11739    +10411     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/pgmpy/pgmpy/pull/1909?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pgmpy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pgmpy).

jihyeseo: Finished implementing the suggestions. 

Sorry for too many small commits. I was planning to use "rebase"... Got a bit messy. 
ankurankan: @jihyeseo Thanks for the PR. I have made a small change to use the logger module instead of warnings to generate the warning. This gives the user a package-wide control over the behavior of logging messages.

Patch:
diff --git a/pgmpy/base/DAG.py b/pgmpy/base/DAG.py
index 8c0fc46a..d8f348c3 100644
--- a/pgmpy/base/DAG.py
+++ b/pgmpy/base/DAG.py
@@ -9,6 +9,7 @@ import pandas as pd
 from pgmpy.base import UndirectedGraph
 from pgmpy.global_vars import logger
 from pgmpy.independencies import Independencies
+from pgmpy.utils.parser import parse_lavaan
 
 
 class DAG(nx.DiGraph):
@@ -71,7 +72,14 @@ class DAG(nx.DiGraph):
     3
     """
 
-    def __init__(self, ebunch=None, latents=set()):
+    def __init__(self, ebunch=None, latents=set(), lavaan_str=None):
+        if lavaan_str:
+            ebunch, latents, err_corr, _ = parse_lavaan(lavaan_str)
+            if err_corr:
+                logger.warning(
+                    f"Residual correlations {err_corr} are ignored in DAG. Use the SEM class to keep them."
+                )
+
         super(DAG, self).__init__(ebunch)
         self.latents = set(latents)
         cycles = []
@@ -85,6 +93,33 @@ class DAG(nx.DiGraph):
             out_str += "".join([f"({u},{v}) " for (u, v) in cycles])
             raise ValueError(out_str)
 
+    @classmethod
+    def from_lavaan(cls, string=None, filename=None):
+        """
+        Initializes a `DAG` instance using lavaan syntax.
+
+        Parameters
+        ----------
+        string: str (default: None)
+            A `lavaan` style multiline set of regression equation representing the model.
+            Refer http://lavaan.ugent.be/tutorial/syntax1.html for details.
+
+        filename: str (default: None)
+            The filename of the file containing the model in lavaan syntax.
+
+        Examples
+        --------
+        """
+        if filename:
+            with open(filename, "r") as f:
+                lavaan_str = f.readlines()
+        elif string:
+            lavaan_str = string.split("\n")
+        else:
+            raise ValueError("Either `filename` or `string` need to be specified")
+
+        return cls(lavaan_str=lavaan_str)
+
     def add_node(self, node, weight=None, latent=False):
         """
         Adds a single node to the Graph.
diff --git a/pgmpy/models/SEM.py b/pgmpy/models/SEM.py
index 0ff03d8a..4a4ae2b8 100644
--- a/pgmpy/models/SEM.py
+++ b/pgmpy/models/SEM.py
@@ -7,6 +7,7 @@ from networkx.algorithms.dag import descendants
 
 from pgmpy.base import DAG
 from pgmpy.global_vars import logger
+from pgmpy.utils.parser import parse_lavaan
 
 
 class SEMGraph(DAG):
@@ -1023,88 +1024,9 @@ class SEM(SEMGraph):
         """
         if syntax.lower() == "lavaan":
             # Create a SEMGraph model using the lavaan str.
-            # Step 0: Check if pyparsing is installed
-            try:
-                from pyparsing import (
-                    OneOrMore,
-                    Optional,
-                    Suppress,
-                    Word,
-                    alphanums,
-                    nums,
-                )
-            except ImportError as e:
-                raise ImportError(
-                    e.msg
-                    + ". pyparsing is required for using lavaan syntax. Please install using: pip install pyparsing"
-                )
-
-            # Step 1: Define the grammar for each type of string.
-            var = Word(alphanums)
-            reg_gram = (
-                OneOrMore(
-                    var.setResultsName("predictors", listAllMatches=True)
-                    + Optional(Suppress("+"))
-                )
-                + "~"
-                + OneOrMore(
-                    var.setResultsName("covariates", listAllMatches=True)
-                    + Optional(Suppress("+"))
-                )
-            )
-            intercept_gram = var("inter_var") + "~" + Word("1")
-            covar_gram = (
-                var("covar_var1")
-                + "~~"
-                + OneOrMore(
-                    var.setResultsName("covar_var2", listAllMatches=True)
-                    + Optional(Suppress("+"))
-                )
-            )
-            latent_gram = (
-                var("latent")
-                + "=~"
-                + OneOrMore(
-                    var.setResultsName("obs", listAllMatches=True)
-                    + Optional(Suppress("+"))
-                )
-            )
+            ebunch, latents, err_corr, err_var = parse_lavaan(kwargs["lavaan_str"])
 
-            # Step 2: Preprocess string to lines
-            lines = kwargs["lavaan_str"]
-
-            # Step 3: Initialize arguments and fill them by parsing each line.
-            ebunch = []
-            latents = []
-            err_corr = []
-            err_var = []
-            for line in lines:
-                line = line.strip()
-                if (line != "") and (not line.startswith("#")):
-                    if intercept_gram.matches(line):
-                        continue
-                    elif reg_gram.matches(line):
-                        results = reg_gram.parseString(line, parseAll=True)
-                        for pred in results["predictors"]:
-                            ebunch.extend(
-                                [
-                                    (covariate, pred)
-                                    for covariate in results["covariates"]
-                                ]
-                            )
-                    elif covar_gram.matches(line):
-                        results = covar_gram.parseString(line, parseAll=True)
-                        for var in results["covar_var2"]:
-                            err_corr.append((results["covar_var1"], var))
-
-                    elif latent_gram.matches(line):
-                        results = latent_gram.parseString(line, parseAll=True)
-                        latents.append(results["latent"])
-                        ebunch.extend(
-                            [(results["latent"], obs) for obs in results["obs"]]
-                        )
-
-            # Step 4: Call the parent __init__ with the arguments
+            # Call the parent __init__ with the arguments
             super(SEM, self).__init__(ebunch=ebunch, latents=latents, err_corr=err_corr)
 
         elif syntax.lower() == "graph":
diff --git a/pgmpy/utils/parser.py b/pgmpy/utils/parser.py
new file mode 100644
index 00000000..7b7ec976
--- /dev/null
+++ b/pgmpy/utils/parser.py
@@ -0,0 +1,75 @@
+def parse_lavaan(lines):
+    # Step 0: Check if pyparsing is installed
+    try:
+        from pyparsing import (
+            OneOrMore,
+            Optional,
+            Suppress,
+            Word,
+            alphanums,
+            nums,
+        )
+    except ImportError as e:
+        raise ImportError(
+            e.msg
+            + ". pyparsing is required for using lavaan syntax. Please install using: pip install pyparsing"
+        )
+
+    # Step 1: Define the grammar for each type of string.
+    var = Word(alphanums)
+    reg_gram = (
+        OneOrMore(
+            var.setResultsName("predictors", listAllMatches=True)
+            + Optional(Suppress("+"))
+        )
+        + "~"
+        + OneOrMore(
+            var.setResultsName("covariates", listAllMatches=True)
+            + Optional(Suppress("+"))
+        )
+    )
+    intercept_gram = var("inter_var") + "~" + Word("1")
+    covar_gram = (
+        var("covar_var1")
+        + "~~"
+        + OneOrMore(
+            var.setResultsName("covar_var2", listAllMatches=True)
+            + Optional(Suppress("+"))
+        )
+    )
+    latent_gram = (
+        var("latent")
+        + "=~"
+        + OneOrMore(
+            var.setResultsName("obs", listAllMatches=True) + Optional(Suppress("+"))
+        )
+    )
+
+    # Step 2: Preprocess string to lines
+
+    # Step 3: Initialize arguments and fill them by parsing each line.
+    ebunch = []
+    latents = []
+    err_corr = []
+    err_var = []
+    for line in lines:
+        line = line.strip()
+        if (line != "") and (not line.startswith("#")):
+            if intercept_gram.matches(line):
+                continue
+            elif reg_gram.matches(line):
+                results = reg_gram.parseString(line, parseAll=True)
+                for pred in results["predictors"]:
+                    ebunch.extend(
+                        [(covariate, pred) for covariate in results["covariates"]]
+                    )
+            elif covar_gram.matches(line):
+                results = covar_gram.parseString(line, parseAll=True)
+                for var in results["covar_var2"]:
+                    err_corr.append((results["covar_var1"], var))
+
+            elif latent_gram.matches(line):
+                results = latent_gram.parseString(line, parseAll=True)
+                latents.append(results["latent"])
+                ebunch.extend([(results["latent"], obs) for obs in results["obs"]])
+    return ebunch, latents, err_corr, err_var


Test patch:
diff --git a/pgmpy/tests/test_base/test_DAG.py b/pgmpy/tests/test_base/test_DAG.py
index 3ad7a8b3..7d9449e6 100644
--- a/pgmpy/tests/test_base/test_DAG.py
+++ b/pgmpy/tests/test_base/test_DAG.py
@@ -1,6 +1,8 @@
 #!/usr/bin/env python3
 
+import os
 import unittest
+import warnings
 
 import networkx as nx
 import numpy as np
@@ -10,8 +12,8 @@ import pgmpy.tests.help_functions as hf
 from pgmpy.base import DAG, PDAG
 from pgmpy.estimators import (
     BayesianEstimator,
-    MaximumLikelihoodEstimator,
     ExpectationMaximization,
+    MaximumLikelihoodEstimator,
 )
 from pgmpy.factors.discrete import TabularCPD
 
@@ -350,6 +352,74 @@ class TestDAGCreation(unittest.TestCase):
         del self.graph
 
 
+class TestDAGParser(unittest.TestCase):
+    def test_from_lavaan(self):
+        model_str = """# %load model.lav
+                       # measurement model
+                         ind60 =~ x1 + x2 + x3
+                         dem60 =~ y1 + y2 + y3 + y4
+                         dem65 =~ y5 + y6 + y7 + y8
+                       # regressions
+                         dem60 ~ ind60
+                         dem65 ~ ind60 + dem60
+                       """
+        model_from_str = DAG.from_lavaan(string=model_str)
+
+        with open("test_model.lav", "w") as f:
+            f.write(model_str)
+        model_from_file = DAG.from_lavaan(filename="test_model.lav")
+        os.remove("test_model.lav")
+
+        expected_edges = set(
+            [
+                ("ind60", "x1"),
+                ("ind60", "x2"),
+                ("ind60", "x3"),
+                ("ind60", "dem60"),
+                ("ind60", "dem65"),
+                ("dem60", "dem65"),
+                ("dem60", "y1"),
+                ("dem60", "y2"),
+                ("dem60", "y3"),
+                ("dem60", "y4"),
+                ("dem65", "y5"),
+                ("dem65", "y6"),
+                ("dem65", "y7"),
+                ("dem65", "y8"),
+            ]
+        )
+
+        expected_latents = set(["dem60", "dem65", "ind60"])
+        self.assertEqual(set(model_from_str.edges()), expected_edges)
+        self.assertEqual(set(model_from_file.edges()), expected_edges)
+        self.assertEqual(set(model_from_str.latents), expected_latents)
+        self.assertEqual(set(model_from_file.latents), expected_latents)
+
+    def test_from_lavaan_with_residual_correlation(self):
+        model_str = """# %load model_with_residual_correlation.lav
+                       # measurement model
+                         ind60 =~ x1 + x2 + x3
+                       # regressions
+                         dem60 ~ ind60
+                       # residual correlations
+                         y1 ~~ y5
+                       """
+
+        model_from_str = DAG.from_lavaan(string=model_str)
+        expected_edges = set(
+            [
+                ("ind60", "x1"),
+                ("ind60", "x2"),
+                ("ind60", "x3"),
+                ("ind60", "dem60"),
+            ]
+        )
+
+        expected_latents = set(["ind60"])
+        self.assertEqual(set(model_from_str.edges()), expected_edges)
+        self.assertEqual(set(model_from_str.latents), expected_latents)
+
+
 class TestDAGMoralization(unittest.TestCase):
     def setUp(self):
         self.graph = DAG()