Repository: pybel/pybel

Issue:
Two layer exporter for PyNPA
Edges is the PPI (normal file) it could be anything it doesn't need to BEL but ideally it should represent proteins since it the functional layer

`startNodeDown.tsv` represents the transcription factors to the nodes in the functional layer. To differentiate the two of them I used r() for the transcription factors and p() for the functional layer but it could be anything like I mentioned.




Discussion:
ddomingof: Add this pipeline before doing any reasoning:
```
    # Preprocessing BEL Graph
    remove_isolated_nodes(bel_graph)
    remove_non_causal_edges(bel_graph)
    # Require PyBEL Tools
    list_abundance_cartesian_expansion(bel_graph)
    reaction_cartesian_expansion(bel_graph)
    collapse_all_variants(bel_graph)
    collapse_to_genes(bel_graph)
```
cthoyt: > Should I do this pipeline before doing any reasoning on my end? or here?
> 
> ```
>     # Preprocessing BEL Graph
>     remove_isolated_nodes(bel_graph)
>     remove_non_causal_edges(bel_graph)
>     # Require PyBEL Tools
>     list_abundance_cartesian_expansion(bel_graph)
>     reaction_cartesian_expansion(bel_graph)
>     collapse_all_variants(bel_graph)
>     collapse_to_genes(bel_graph)
> ```

- Since this works on the edge list, isolated nodes are implicitly skipped
- it only looks at causal edges (see line 70 of the pybel.io.pynpa)
- it's not applying the cartesian expansion for reactions or list abundances, but they made it in this PR. I added that as an option (false by default) in 1a76cfb0
- collapsing variants and genes happens in the _normalize_genes function.

Overall, all taken care of! Just waiting for tests to pass on Travis then merging
cthoyt: No merge until tests written / pass
codecov[bot]: # [Codecov](https://codecov.io/gh/pybel/pybel/pull/413?src=pr&el=h1) Report
> :exclamation: No coverage uploaded for pull request base (`master@c54e275`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference#section-missing-base-commit).
> The diff coverage is `45.91%`.

[![Impacted file tree graph](https://codecov.io/gh/pybel/pybel/pull/413/graphs/tree.svg?width=650&token=J7joRTRygG&height=150&src=pr)](https://codecov.io/gh/pybel/pybel/pull/413?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##             master     #413   +/-   ##
=========================================
  Coverage          ?   83.91%           
=========================================
  Files             ?      156           
  Lines             ?     7654           
  Branches          ?     1070           
=========================================
  Hits              ?     6423           
  Misses            ?      988           
  Partials          ?      243
```


| [Impacted Files](https://codecov.io/gh/pybel/pybel/pull/413?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/pybel/struct/\_\_init\_\_.py](https://codecov.io/gh/pybel/pybel/pull/413/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9fX2luaXRfXy5weQ==) | `100% <100%> (ø)` | |
| [src/pybel/io/\_\_init\_\_.py](https://codecov.io/gh/pybel/pybel/pull/413/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL19faW5pdF9fLnB5) | `100% <100%> (ø)` | |
| [src/pybel/io/pynpa.py](https://codecov.io/gh/pybel/pybel/pull/413/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2lvL3B5bnBhLnB5) | `31.48% <31.48%> (ø)` | |
| [src/pybel/struct/node\_utils.py](https://codecov.io/gh/pybel/pybel/pull/413/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9ub2RlX3V0aWxzLnB5) | `45.63% <45.63%> (ø)` | |
| [src/pybel/dsl/node\_classes.py](https://codecov.io/gh/pybel/pybel/pull/413/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL2RzbC9ub2RlX2NsYXNzZXMucHk=) | `90.07% <50%> (ø)` | |
| [src/pybel/struct/getters.py](https://codecov.io/gh/pybel/pybel/pull/413/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9nZXR0ZXJzLnB5) | `65.62% <65.62%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pybel/pybel/pull/413?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pybel/pybel/pull/413?src=pr&el=footer). Last update [c54e275...00c9749](https://codecov.io/gh/pybel/pybel/pull/413?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 9be303ac..df46e131 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -13,6 +13,7 @@ Added
   `PyKEEN <https://pykeen.readthedocs.io>`_.
 - Update TSV exporter for better ML-ready triples for PyKEEN
 - More INDRA I/O options
+- Add PyNPA Exporter (https://github.com/pybel/pybel/pull/413)
 
 `0.14.5 <https://github.com/pybel/pybel/compare/v0.14.4...v0.14.5>`_ - 2020-02-26
 ---------------------------------------------------------------------------------
diff --git a/docs/source/reference/io.rst b/docs/source/reference/io.rst
index 79e12439..f7351e77 100644
--- a/docs/source/reference/io.rst
+++ b/docs/source/reference/io.rst
@@ -158,6 +158,13 @@ GraphML
 
 .. autofunction:: pybel.to_graphml
 
+PyNPA
+~~~~~
+.. automodule:: pybel.io.pynpa
+
+.. autofunction:: pybel.to_npa_directory
+.. autofunction:: pybel.to_npa_dfs
+
 Miscellaneous
 ~~~~~~~~~~~~~
 .. automodule:: pybel.io.extras
diff --git a/src/pybel/dsl/node_classes.py b/src/pybel/dsl/node_classes.py
index c8ddb38f..0613ddd7 100644
--- a/src/pybel/dsl/node_classes.py
+++ b/src/pybel/dsl/node_classes.py
@@ -570,6 +570,17 @@ class Gene(CentralDogma):
 
     function = GENE
 
+    def get_rna(self) -> 'Rna':
+        """Get the corresponding RNA."""
+        if self.variants:
+            raise InferCentralDogmaException('can not get gene for variant')
+        return Rna(
+            namespace=self.namespace,
+            name=self.name,
+            identifier=self.identifier,
+            xrefs=self.xrefs,
+        )
+
 
 class _Transcribable(CentralDogma):
     """A base class for RNA and micro-RNA to share getting of their corresponding genes."""
diff --git a/src/pybel/io/__init__.py b/src/pybel/io/__init__.py
index 570f193b..c195fc6c 100644
--- a/src/pybel/io/__init__.py
+++ b/src/pybel/io/__init__.py
@@ -31,6 +31,7 @@ from .nodelink import (
     from_nodelink, from_nodelink_file, from_nodelink_gz, from_nodelink_jsons, to_nodelink,
     to_nodelink_file, to_nodelink_gz, to_nodelink_jsons,
 )
+from .pynpa import to_npa_dfs, to_npa_directory
 from .tsv import to_edgelist, to_tsv
 from .umbrella_nodelink import to_umbrella_nodelink, to_umbrella_nodelink_file, to_umbrella_nodelink_gz
 from .web import from_web, to_web
diff --git a/src/pybel/io/pynpa.py b/src/pybel/io/pynpa.py
new file mode 100644
index 00000000..262a21f5
--- /dev/null
+++ b/src/pybel/io/pynpa.py
@@ -0,0 +1,157 @@
+# -*- coding: utf-8 -*-
+
+"""Exporter for PyNPA.
+
+.. seealso:: https://github.com/pynpa
+"""
+
+import logging
+import os
+from typing import List, Mapping, Optional, Tuple
+
+import pandas as pd
+
+from ..constants import CAUSAL_DECREASE_RELATIONS, CAUSAL_INCREASE_RELATIONS, RELATION
+from ..dsl import Gene, MicroRna, Protein, Rna
+from ..struct import BELGraph
+from ..struct.getters import get_tf_pairs
+from ..struct.node_utils import list_abundance_cartesian_expansion, reaction_cartesian_expansion
+
+__all__ = [
+    'to_npa_directory',
+    'to_npa_dfs',
+    'to_npa_layers',
+]
+
+logger = logging.getLogger(__name__)
+
+Layer = Mapping[Tuple[Gene, Gene], int]
+
+#: Code to distinguish between between iNodes when nodes have been debelized
+DEBELIZED_CODE_FOR_INODES = "*"
+
+
+def to_npa_directory(graph: BELGraph, directory: str, **kwargs) -> None:
+    """Write the BEL file to two files in the directory for :mod:`pynpa`."""
+    ppi_df, transcription_df = to_npa_dfs(graph, **kwargs)
+    ppi_df.to_csv(os.path.join(directory, 'ppi_layer.tsv'), sep='\t', index=False)
+    transcription_df.to_csv(os.path.join(directory, 'transcriptional_layer.tsv'), sep='\t', index=False)
+
+
+def to_npa_dfs(
+    graph: BELGraph,
+    cartesian_expansion: bool = False,
+    nomenclature_method_first_layer: Optional[str] = None,
+    nomenclature_method_second_layer: Optional[str] = None,
+    direct_tf_only: bool = False,
+) -> Tuple[pd.DataFrame, pd.DataFrame]:
+    """Export the BEL graph as two lists of triples for the :mod:`pynpa`.
+
+    :param graph: A BEL graph
+    :param cartesian_expansion: If true, applies cartesian expansion on both reactions (reactants x products)
+     as well as list abundances using :func:`list_abundance_cartesian_expansion` and
+     :func:`reaction_cartesian_expansion`
+    :param nomenclature_method_first_layer: Either "curie", "name" or "inodes. Defaults to "curie".
+    :param nomenclature_method_second_layer: Either "curie", "name" or "inodes. Defaults to "curie".
+
+    1. Pick out all transcription factor relationships. Protein X is a transcription
+       factor for gene Y IFF ``complex(p(X), g(Y)) -> r(Y)``
+    2. Get all other interactions between any gene/rna/protein that are directed causal
+       for the PPI layer
+    """
+    ppi_layer, transcription_layer = to_npa_layers(
+        graph,
+        cartesian_expansion=cartesian_expansion,
+        direct_tf_only=direct_tf_only,
+    )
+    return (
+        _get_df(ppi_layer, method=nomenclature_method_first_layer),
+        _get_df(transcription_layer, method=nomenclature_method_second_layer),
+    )
+
+
+def _get_df(layer: Layer, method: Optional[str] = None) -> pd.DataFrame:
+    rows = _normalize_layer(layer, method=method)
+    return pd.DataFrame(rows, columns=['source', 'target', 'relation']).sort_values(['source', 'target'])
+
+
+def _normalize_layer(layer: Layer, method: Optional[str] = None) -> List[Tuple[str, str, int]]:
+    if method == 'curie' or method is None:
+        return [
+            (source.curie, target.curie, direction)
+            for (source, target), direction in layer.items()
+        ]
+    elif method == 'name':
+        return [
+            (source.name, target.name, direction)
+            for (source, target), direction in layer.items()
+        ]
+    elif method == 'inodes':
+        return [
+            (
+                "{}{}".format(DEBELIZED_CODE_FOR_INODES, source.name),
+                "{}{}".format(DEBELIZED_CODE_FOR_INODES, target.name),
+                direction,
+            )
+            for (source, target), direction in layer.items()
+        ]
+    else:
+        raise ValueError('Invalid export method: {method}'.format(method=method))
+
+
+def to_npa_layers(
+    graph: BELGraph,
+    cartesian_expansion: bool = False,
+    direct_tf_only: bool = False,
+) -> Tuple[Layer, Layer]:
+    """Get the two layers for the network.
+
+    :param graph: A BEL graph
+    :param cartesian_expansion: If true, applies cartesian expansion on both reactions (reactants x products)
+     as well as list abundances using :func:`list_abundance_cartesian_expansion` and
+     :func:`reaction_cartesian_expansion`
+    :param direct_tf_only: If true, only uses directlyIncreases and directlyDecreases relations for TF relations
+     ``complex(p(X), g(Y)) =>/=| r(Y)``. If false, also allows indirect relations ``complex(p(X), g(Y)) ->/-| r(Y)``.
+    """
+    if cartesian_expansion:
+        list_abundance_cartesian_expansion(graph)
+        reaction_cartesian_expansion(graph)
+
+    transcription_layer = {
+        (u.get_rna().get_gene(), v.get_gene()): r
+        for u, v, r in get_tf_pairs(graph, direct_only=direct_tf_only)
+    }
+    logger.info('extracted %d pairs for the transcription layer', len(transcription_layer))
+
+    ppi_layer = {}
+    for u, v, d in graph.edges(data=True):
+        u, v = _normalize(u), _normalize(v)
+        if u is None or v is None:
+            continue
+        if (u, v) in transcription_layer:
+            continue
+        relation = d[RELATION]
+
+        if relation in CAUSAL_INCREASE_RELATIONS:
+            ppi_layer[u, v] = +1
+        elif relation in CAUSAL_DECREASE_RELATIONS:
+            ppi_layer[u, v] = -1
+        # TODO what about contradictions
+
+    logger.info('extracted %d pairs for the ppi layer', len(ppi_layer))
+    return ppi_layer, transcription_layer
+
+
+def _normalize(n):
+    if isinstance(n, Protein):
+        if n.variants:
+            n = n.get_parent()
+        n = n.get_rna()
+    if isinstance(n, (Rna, MicroRna)):
+        if n.variants:
+            n = n.get_parent()
+        n = n.get_gene()
+    if isinstance(n, Gene):
+        if n.variants:
+            n = n.get_parent()
+        return n
diff --git a/src/pybel/struct/__init__.py b/src/pybel/struct/__init__.py
index 3e2fa912..a4b1f903 100644
--- a/src/pybel/struct/__init__.py
+++ b/src/pybel/struct/__init__.py
@@ -18,11 +18,12 @@ code. Because the data structure is the same in Neo4J, the data can be directly
 Neo4J supports the Cypher querying language so that the same queries can be written in an elegant and simple way.
 """
 
-from . import filters, graph, grouping, mutation, operations, summary
+from . import filters, graph, grouping, mutation, node_utils, operations, summary
 from .filters import *
 from .graph import *
 from .grouping import *
 from .mutation import *
+from .node_utils import *
 from .operations import *
 from .pipeline import Pipeline
 from .query import Query
@@ -35,6 +36,7 @@ __all__ = (
     + filters.__all__
     + summary.__all__
     + mutation.__all__
+    + node_utils.__all__
     + [
         'Pipeline',
         'Query',
diff --git a/src/pybel/struct/getters.py b/src/pybel/struct/getters.py
new file mode 100644
index 00000000..efa82801
--- /dev/null
+++ b/src/pybel/struct/getters.py
@@ -0,0 +1,61 @@
+# -*- coding: utf-8 -*-
+
+"""Misc. getters."""
+
+from typing import Iterable, Tuple
+
+from .graph import BELGraph
+from ..constants import (
+    CAUSAL_DECREASE_RELATIONS, CAUSAL_INCREASE_RELATIONS, DIRECTLY_DECREASES, DIRECTLY_INCREASES,
+    RELATION,
+)
+from ..dsl import ComplexAbundance, Gene, Protein, Rna
+
+__all__ = [
+    'get_tf_pairs',
+]
+
+
+def get_tf_pairs(graph: BELGraph, direct_only: bool = False) -> Iterable[Tuple[Protein, Rna, int]]:
+    """Iterate pairs of ``p(X)`` and ``r(Y)`` such that ``complex(p(X), g(Y)) -> r(Y)``.
+
+    :param graph: A BEL graph
+    :param direct_only: If true, only uses directlyIncreases and directlyDecreases relations. Otherwise, allows
+     indirect relations.
+    """
+    if direct_only:
+        _inc, _dec = {DIRECTLY_INCREASES}, {DIRECTLY_DECREASES}
+    else:
+        _inc, _dec = CAUSAL_INCREASE_RELATIONS, CAUSAL_DECREASE_RELATIONS
+
+    for tf in _iterate_proteins(graph):
+        for tf_gene in graph[tf]:
+            if not isinstance(tf_gene, ComplexAbundance):
+                continue
+            if tf not in tf_gene.members:
+                continue
+            other_members = [m for m in tf_gene.members if m != tf]
+            if 1 != len(other_members):
+                continue
+            target_gene = other_members[0]
+            if not isinstance(target_gene, Gene):
+                continue
+            if target_gene.variants:
+                target_gene = target_gene.get_parent()
+            target_rna = target_gene.get_rna()
+            if target_rna not in graph:
+                continue
+            for edge in graph[tf_gene][target_rna].values():
+                relation = edge[RELATION]
+                if relation in _inc:
+                    yield tf, target_rna, +1
+                elif relation in _dec:
+                    yield tf, target_rna, -1
+
+
+def _iterate_proteins(graph: BELGraph) -> Iterable[Protein]:
+    return (
+        node
+        for node in graph
+        if isinstance(node, Protein)
+    )
diff --git a/src/pybel/struct/node_utils.py b/src/pybel/struct/node_utils.py
new file mode 100644
index 00000000..42a6f0f6
--- /dev/null
+++ b/src/pybel/struct/node_utils.py
@@ -0,0 +1,266 @@
+# -*- coding: utf-8 -*-
+
+"""Utilities for handling nodes."""
+
+import itertools as itt
+import logging
+from itertools import chain
+from typing import Set, Tuple, Type, Union
+
+from networkx import relabel_nodes
+
+from ..constants import ANNOTATIONS, CITATION, EVIDENCE, INCREASES, RELATION
+from ..dsl import BaseAbundance, BaseEntity, ListAbundance, Reaction
+
+__all__ = [
+    'flatten_list_abundance',
+    'list_abundance_cartesian_expansion',
+    'reaction_cartesian_expansion',
+]
+
+logger = logging.getLogger(__name__)
+
+
+def flatten_list_abundance(node: ListAbundance) -> ListAbundance:
+    """Flattens the complex or composite abundance."""
+    return node.__class__(list(chain.from_iterable(
+        (
+            flatten_list_abundance(member).members
+            if isinstance(member, ListAbundance) else
+            [member]
+        )
+        for member in node.members
+    )))
+
+
+def list_abundance_expansion(graph) -> None:
+    """Flatten list abundances."""
+    mapping = {
+        node: flatten_list_abundance(node)
+        for node in graph
+        if isinstance(node, ListAbundance)
+    }
+    relabel_nodes(graph, mapping, copy=False)
+
+
+def list_abundance_cartesian_expansion(graph) -> None:
+    """Expand all list abundances to simple subject-predicate-object networks."""
+    for u, v, d in list(graph.edges(data=True)):
+        if CITATION not in d:
+            continue
+
+        if isinstance(u, ListAbundance) and isinstance(v, ListAbundance):
+            for u_member, v_member in itt.product(u.members, v.members):
+                graph.add_qualified_edge(
+                    u_member, v_member,
+                    relation=d[RELATION],
+                    citation=d.get(CITATION),
+                    evidence=d.get(EVIDENCE),
+                    annotations=d.get(ANNOTATIONS),
+                )
+
+        elif isinstance(u, ListAbundance):
+            for member in u.members:
+                graph.add_qualified_edge(
+                    member, v,
+                    relation=d[RELATION],
+                    citation=d.get(CITATION),
+                    evidence=d.get(EVIDENCE),
+                    annotations=d.get(ANNOTATIONS),
+                )
+
+        elif isinstance(v, ListAbundance):
+            for member in v.members:
+                graph.add_qualified_edge(
+                    u, member,
+                    relation=d[RELATION],
+                    citation=d.get(CITATION),
+                    evidence=d.get(EVIDENCE),
+                    annotations=d.get(ANNOTATIONS),
+                )
+
+    _remove_list_abundance_nodes(graph)
+
+
+def _reaction_cartesian_expansion_unqualified_helper(
+    graph,
+    u: BaseEntity,
+    v: BaseEntity,
+    d: dict,
+) -> None:
+    """Help deal with cartesian expansion in unqualified edges."""
+    if isinstance(u, Reaction) and isinstance(v, Reaction):
+        enzymes = _get_catalysts_in_reaction(u) | _get_catalysts_in_reaction(v)
+
+        for reactant, product in chain(itt.product(u.reactants, u.products),
+                                       itt.product(v.reactants, v.products)):
+            if reactant in enzymes or product in enzymes:
+                continue
+
+            graph.add_unqualified_edge(
+                reactant, product, INCREASES
+            )
+
+        for product, reactant in itt.product(u.products, u.reactants):
+            if reactant in enzymes or product in enzymes:
+                continue
+
+            graph.add_unqualified_edge(
+                product, reactant, d[RELATION],
+            )
+
+    elif isinstance(u, Reaction):
+        enzymes = _get_catalysts_in_reaction(u)
+
+        for product in u.products:
+            # Skip create increases edges between enzymes
+            if product in enzymes:
+                continue
+
+            # Only add edge between v and reaction if the node is not part of the reaction
+            # In practice skips hasReactant, hasProduct edges
+            if v not in u.products and v not in u.reactants:
+                graph.add_unqualified_edge(
+                    product, v, INCREASES
+                )
+            for reactant in u.reactants:
+                graph.add_unqualified_edge(
+                    reactant, product, INCREASES
+                )
+
+    elif isinstance(v, Reaction):
+        enzymes = _get_catalysts_in_reaction(v)
+
+        for reactant in v.reactants:
+            # Skip create increases edges between enzymes
+            if reactant in enzymes:
+                continue
+
+            # Only add edge between v and reaction if the node is not part of the reaction
+            # In practice skips hasReactant, hasProduct edges
+            if u not in v.products and u not in v.reactants:
+                graph.add_unqualified_edge(
+                    u, reactant, INCREASES
+                )
+            for product in v.products:
+                graph.add_unqualified_edge(
+                    reactant, product, INCREASES
+                )
+
+
+def _get_catalysts_in_reaction(reaction: Reaction) -> Set[BaseAbundance]:
+    """Return nodes that are both in reactants and reactions in a reaction."""
+    # TODO replace with reaction.get_catalysts()
+    return set(reaction.reactants).intersection(reaction.products)
+
+
+def reaction_cartesian_expansion(graph, accept_unqualified_edges: bool = True) -> None:
+    """Expand all reactions to simple subject-predicate-object networks."""
+    for u, v, d in list(graph.edges(data=True)):
+        # Deal with unqualified edges
+        if CITATION not in d and accept_unqualified_edges:
+            _reaction_cartesian_expansion_unqualified_helper(graph, u, v, d)
+            continue
+
+        if isinstance(u, Reaction) and isinstance(v, Reaction):
+            catalysts = _get_catalysts_in_reaction(u) | _get_catalysts_in_reaction(v)
+
+            for reactant, product in chain(itt.product(u.reactants, u.products), itt.product(v.reactants, v.products)):
+                if reactant in catalysts or product in catalysts:
+                    continue
+                graph.add_increases(
+                    reactant, product,
+                    citation=d.get(CITATION),
+                    evidence=d.get(EVIDENCE),
+                    annotations=d.get(ANNOTATIONS),
+                )
+
+            for product, reactant in itt.product(u.products, u.reactants):
+                if reactant in catalysts or product in catalysts:
+                    continue
+
+                graph.add_qualified_edge(
+                    product, reactant,
+                    relation=d[RELATION],
+                    citation=d.get(CITATION),
+                    evidence=d.get(EVIDENCE),
+                    annotations=d.get(ANNOTATIONS),
+                )
+
+        elif isinstance(u, Reaction):
+            catalysts = _get_catalysts_in_reaction(u)
+
+            for product in u.products:
+                # Skip create increases edges between enzymes
+                if product in catalysts:
+                    continue
+
+                # Only add edge between v and reaction if the node is not part of the reaction
+                # In practice skips hasReactant, hasProduct edges
+                if v not in u.products and v not in u.reactants:
+                    graph.add_increases(
+                        product, v,
+                        citation=d.get(CITATION),
+                        evidence=d.get(EVIDENCE),
+                        annotations=d.get(ANNOTATIONS),
+                    )
+
+                for reactant in u.reactants:
+                    graph.add_increases(
+                        reactant, product,
+                        citation=d.get(CITATION),
+                        evidence=d.get(EVIDENCE),
+                        annotations=d.get(ANNOTATIONS),
+                    )
+
+        elif isinstance(v, Reaction):
+            catalysts = _get_catalysts_in_reaction(v)
+
+            for reactant in v.reactants:
+                # Skip create increases edges between enzymes
+                if reactant in catalysts:
+                    continue
+
+                # Only add edge between v and reaction if the node is not part of the reaction
+                # In practice skips hasReactant, hasProduct edges
+                if u not in v.products and u not in v.reactants:
+                    graph.add_increases(
+                        u, reactant,
+                        citation=d.get(CITATION),
+                        evidence=d.get(EVIDENCE),
+                        annotations=d.get(ANNOTATIONS),
+                    )
+                for product in v.products:
+                    graph.add_increases(
+                        reactant, product,
+                        citation=d.get(CITATION),
+                        evidence=d.get(EVIDENCE),
+                        annotations=d.get(ANNOTATIONS),
+                    )
+
+    _remove_reaction_nodes(graph)
+
+
+def remove_reified_nodes(graph) -> None:
+    """Remove complex nodes."""
+    _remove_list_abundance_nodes(graph)
+    _remove_reaction_nodes(graph)
+
+
+def _remove_list_abundance_nodes(graph):
+    _remove_typed_nodes(graph, ListAbundance)
+
+
+def _remove_reaction_nodes(graph):
+    _remove_typed_nodes(graph, Reaction)
+
+
+def _remove_typed_nodes(
+    graph,
+    cls: Union[Type[BaseEntity], Tuple[Type[BaseEntity], ...]],
+) -> None:
+    graph.remove_nodes_from({
+        node
+        for node in graph
+        if isinstance(node, cls)
+    })


Test patch:
diff --git a/tests/test_io/test_pynpa.py b/tests/test_io/test_pynpa.py
new file mode 100644
index 00000000..1d16ee25
--- /dev/null
+++ b/tests/test_io/test_pynpa.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+
+"""Tests for PyNPA."""
+
+import unittest
+
+import pandas as pd
+
+from pybel import BELGraph
+from pybel.dsl import ComplexAbundance, Gene, Protein, Rna
+from pybel.io.pynpa import to_npa_dfs, to_npa_layers
+from pybel.struct.getters import get_tf_pairs
+
+g1 = Gene('hgnc', '1')
+r1 = Rna('hgnc', '1')
+p1 = Protein('hgnc', '1')
+g2 = Gene('hgnc', '2')
+r2 = Rna('hgnc', '2')
+p2 = Protein('hgnc', '2')
+g3 = Gene('hgnc', '3')
+p3 = Protein('hgnc', '3')
+
+
+class TestPyNPA(unittest.TestCase):
+    """Tests for PyNPA."""
+
+    def setUp(self) -> None:
+        """Set up a small test graph."""
+        self.graph = BELGraph()
+        self.graph.add_increases(ComplexAbundance([p1, g2]), r2, citation='', evidence='')
+        self.graph.add_increases(p2, p3, citation='', evidence='')
+
+    def test_get_tf_pairs(self):
+        """Test getting transcription factor-target pairs."""
+        tf_pairs = set(get_tf_pairs(self.graph))
+        self.assertLess(0, len(tf_pairs), msg='No TF pairs pairs found')
+        self.assertEqual(1, len(tf_pairs))
+        example_tf, example_target, _ = list(tf_pairs)[0]
+        self.assertEqual(p1, example_tf)
+        self.assertEqual(r2, example_target)
+
+    def test_export_layers(self):
+        """Test that the layers are exported right."""
+        ppi_layer, tf_layer = to_npa_layers(self.graph)
+        self.assertIsInstance(ppi_layer, dict)
+        self.assertIsInstance(tf_layer, dict)
+        self.assertLess(0, len(ppi_layer), msg='PPI layer was not populated')
+        self.assertLess(0, len(tf_layer), msg='TF layer was not populated')
+        self.assertIn((g2, g3), ppi_layer)
+        self.assertEqual(+1, ppi_layer[g2, g3])
+        self.assertIn((g1, g2), tf_layer)
+        self.assertEqual(+1, tf_layer[g1, g2])
+
+    def test_export_df_curie(self):
+        """Test exporting dataframes with curie-based nomenclature."""
+        ppi_df, tf_df = to_npa_dfs(self.graph)
+        self.assertIsInstance(ppi_df, pd.DataFrame)
+        self.assertIsInstance(tf_df, pd.DataFrame)
+        self.assertEqual(1, len(ppi_df.index))
+        self.assertEqual(1, len(tf_df.index))
+        self.assertEqual(('hgnc:2', 'hgnc:3', 1), tuple(list(ppi_df.values)[0]))
+        self.assertEqual(('hgnc:1', 'hgnc:2', 1), tuple(list(tf_df.values)[0]))
+
+    def test_export_df_name(self):
+        """Test exporting dataframes with name-based nomenclature."""
+        ppi_df, tf_df = to_npa_dfs(
+            self.graph,
+            nomenclature_method_first_layer='name',
+            nomenclature_method_second_layer='name',
+        )
+        self.assertEqual(('2', '3', 1), tuple(list(ppi_df.values)[0]))
+        self.assertEqual(('1', '2', 1), tuple(list(tf_df.values)[0]))
+
+    def test_export_df_inode(self):
+        """Test exporting dataframes with inode-based nomenclature in the second layer."""
+        ppi_df, tf_df = to_npa_dfs(
+            self.graph,
+            nomenclature_method_first_layer='name',
+            nomenclature_method_second_layer='inodes',
+        )
+        self.assertEqual(('2', '3', 1), tuple(list(ppi_df.values)[0]))
+        self.assertEqual(('*1', '*2', 1), tuple(list(tf_df.values)[0]))
diff --git a/tests/test_struct/test_getters.py b/tests/test_struct/test_getters.py
new file mode 100644
index 00000000..aff5cfd8
--- /dev/null
+++ b/tests/test_struct/test_getters.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+
+"""Tests for getters."""
+
+import unittest
+
+from pybel import BELGraph
+from pybel.dsl import ComplexAbundance, Protein, Rna
+from pybel.struct.getters import get_tf_pairs
+from pybel.testing.utils import n
+
+
+def _tf_up(graph, protein, rna):
+    graph.add_directly_increases(
+        ComplexAbundance([protein, rna.get_gene()]),
+        rna,
+        citation=n(),
+        evidence=n(),
+    )
+
+
+def _tf_down(graph, protein, rna):
+    graph.add_directly_decreases(
+        ComplexAbundance([protein, rna.get_gene()]),
+        rna,
+        citation=n(),
+        evidence=n(),
+    )
+
+
+def _bel_pair_key(k):
+    return tuple(map(str, k))
+
+
+class TestGetters(unittest.TestCase):
+    """Tests for getters."""
+
+    def test_get_tf_pairs(self):
+        """Test iterating over transcription factor pairs."""
+        graph = BELGraph()
+        p1, p2, p3 = (Protein('test', str(i)) for i in range(1, 4))
+        r4, r5, r6 = (Rna('test', str(j)) for j in range(4, 7))
+
+        g4 = r4.get_gene()
+        self.assertIsNotNone(g4)
+        g5 = r5.get_gene()
+        self.assertIsNotNone(g5)
+
+        c14, c25 = ComplexAbundance([p1, g4]), ComplexAbundance([p2, g5])
+        _tf_up(graph, p1, r4)
+        _tf_down(graph, p2, r5)
+        graph.add_correlation(p3, r6, citation=n(), evidence=n())
+
+        self.assertEqual({p1, p2, p3, r4, r5, r6, g4, g5, c14, c25}, set(graph))
+
+        expected_edges = [
+            (c14, r4), (p1, c14), (g4, c14),
+            (c25, r5), (p2, c25), (g5, c25),
+            (p3, r6), (r6, p3),
+        ]
+        sorted_expected_edges = sorted(expected_edges, key=_bel_pair_key)
+        sorted_actual_edges = sorted(graph.edges(), key=_bel_pair_key)
+
+        self.assertEqual(sorted_expected_edges, sorted_actual_edges)
+
+        pairs = set(get_tf_pairs(graph))
+        expected_pairs = {(p1, r4, +1), (p2, r5, -1)}
+        self.assertEqual(expected_pairs, pairs)
diff --git a/tests/test_struct/test_node_utils.py b/tests/test_struct/test_node_utils.py
new file mode 100644
index 00000000..840488fc
--- /dev/null
+++ b/tests/test_struct/test_node_utils.py
@@ -0,0 +1,111 @@
+# -*- coding: utf-8 -*-
+
+"""Tests for node utilities."""
+
+import unittest
+
+from pybel import BELGraph
+from pybel.constants import INCREASES
+from pybel.dsl import ComplexAbundance as g, CompositeAbundance as c, Protein, Reaction
+from pybel.examples.various_example import adp, atp, glucose, glucose_6_phosphate, hk1, phosphate, single_reaction_graph
+from pybel.struct.node_utils import flatten_list_abundance, reaction_cartesian_expansion
+
+
+class TestNodeUtils(unittest.TestCase):
+    """Test node utilities."""
+
+    def test_flatten_complex(self):
+        """Test flattening a nested complex."""
+        p1, p2, p3 = (Protein('N', str(i + 1)) for i in range(3))
+
+        pairs = [
+            # Mainly complexes
+            (g([p1, p2, p3]), g([p1, p2, p3])),  # no nesting
+            (g([p1, p2, p3]), g([g([p1, p2]), p3])),  # one nesting
+            (g([p1, p2, p3]), g([g([p1]), p2, p3])),  # one nesting
+            (g([p1, p2, p3]), g([g([p1]), g([p2]), p3])),  # one nesting
+            # Mainly composites
+            (c([p1, p2, p3]), c([p1, p2, p3])),  # no nesting
+            (c([p1, p2, p3]), c([c([p1, p2]), p3])),  # one nesting
+            (c([p1, p2, p3]), c([c([p1]), p2, p3])),  # one nesting
+            (c([p1, p2, p3]), c([c([p1]), c([p2]), p3])),  # one nesting
+            # TODO: mixtures of composites and complexes?
+        ]
+
+        for expected, source in pairs:
+            self.assertEqual(expected, flatten_list_abundance(source))
+
+    def test_flatten_reaction(self):
+        """Test flattening a reaction."""
+        single_reaction_graph_copy = single_reaction_graph.copy()
+
+        self.assertEqual(single_reaction_graph_copy.number_of_nodes(), 7)
+        self.assertEqual(single_reaction_graph_copy.number_of_edges(), 7)
+
+        reaction_cartesian_expansion(single_reaction_graph_copy)
+
+        self.assertEqual(single_reaction_graph_copy.number_of_nodes(), 6)
+        self.assertEqual(single_reaction_graph_copy.number_of_edges(), 8)
+
+        pairs = [
+            (glucose, INCREASES, glucose_6_phosphate),
+            (glucose, INCREASES, adp),
+            (hk1, INCREASES, glucose_6_phosphate),
+            (hk1, INCREASES, adp),
+            (atp, INCREASES, glucose_6_phosphate),
+            (atp, INCREASES, adp),
+            (phosphate, INCREASES, glucose_6_phosphate),
+            (phosphate, INCREASES, adp),
+        ]
+
+        for source, target, data in single_reaction_graph_copy.edges(data=True):
+            self.assertIn((source, INCREASES, target), pairs)
+
+    def test_flatten_reaction_2(self):
+        """Test flattening a qualified reaction."""
+        node_increases_reaction_graph = BELGraph()
+
+        glycolisis_step_1 = Reaction(reactants=[glucose, hk1, atp], products=[glucose_6_phosphate, adp, hk1])
+
+        node_increases_reaction_graph.add_increases(glucose_6_phosphate, glycolisis_step_1, citation='X', evidence='X')
+
+        self.assertEqual(node_increases_reaction_graph.number_of_nodes(), 6)
+        self.assertEqual(node_increases_reaction_graph.number_of_edges(), 7)
+
+        reaction_cartesian_expansion(node_increases_reaction_graph)
+
+        self.assertEqual(node_increases_reaction_graph.number_of_nodes(), 5)
+        # TODO Fix so unqualified duplicate edges are not created (it should be the 8 edges below)
+        self.assertEqual(node_increases_reaction_graph.number_of_edges(), 12)
+
+        # pairs = [
+        #     (glucose, INCREASES, glucose_6_phosphate),
+        #     (glucose, INCREASES, adp),
+        #     (hk1, INCREASES, glucose_6_phosphate),
+        #     (hk1, INCREASES, adp),
+        #     (atp, INCREASES, glucose_6_phosphate),
+        #     (atp, INCREASES, adp),
+        #     (phosphate, INCREASES, glucose_6_phosphate),
+        #     (phosphate, INCREASES, adp),
+        # ]
+        #
+        # for source, target, data in node_increases_reaction_graph.edges(data=True):
+        #     self.assertIn((source, INCREASES, target), pairs)
+
+    def test_flatten_reaction_3(self):
+        """Test flattening a graph containing 2 reactions connected to each other."""
+        two_reactions_graph = BELGraph()
+
+        reaction_1 = Reaction(reactants=[glucose, atp], products=hk1)
+        reaction_2 = Reaction(reactants=glucose_6_phosphate, products=adp)
+
+        two_reactions_graph.add_increases(reaction_1, reaction_2, citation='X', evidence='X')
+
+        self.assertEqual(two_reactions_graph.number_of_nodes(), 7)
+        self.assertEqual(two_reactions_graph.number_of_edges(), 6)
+
+        reaction_cartesian_expansion(two_reactions_graph)
+
+        # TODO Fix so unqualified duplicate edges are not created (it should be the 6 edges below)
+        self.assertEqual(two_reactions_graph.number_of_nodes(), 5)
+        self.assertEqual(two_reactions_graph.number_of_edges(), 8)