Repository: networkx/networkx

Issue:
Allow caller to opt out of adding `weight` attributes in `from_numpy_array`
### Current Behavior

<!--- Tell us what happens instead of the expected behavior -->

Right now, calls to `from_numpy_array` add a `weight` attribute to all edges in the newly created (non-multi)graph. 

### Expected Behavior

<!--- Tell us what should happen -->
It would be nice to allow the user to opt out of this behavior, or to specify an attribute that should receive this weight.

### Steps to Reproduce

<!--- Provide a minimal example that reproduces the bug -->
```python
import networkx as nx
import numpy as np

mat = np.array([
    [0, 1],
    [1, 0]
])
g = nx.from_numpy_array(mat, create_using=nx.DiGraph)
print(g.edges(data=True))
```

```
[(0, 1, {'weight': 1}), (1, 0, {'weight': 1})]
```

### Environment

<!--- Please provide details about your local environment -->

* Python version: 3.10
* NetworkX version: All (2.6.3)

### Additional context

<!--- Add any other context about the problem here, screenshots, etc. -->

This is confusing behavior because the caller does not explicitly request attribute creation. I would propose the following PREFERRED behavior:

#### Preferred

Creating a graph with an array of `dtype` ∈ {int, float} creates an attribute of name `weight_attribute`, which defaults to `w` for backwards compat.

```python
g = nx.from_numpy_array(mat, create_using=nx.DiGraph, weight_attribute="w")
print(g.edges(data=True))
```

```
[(0, 1, {'w': 1}), (1, 0, {'w': 1})]
```

```

Creating a graph with an array of `dtype` = bool treats each value as an existence flag and does not create an attribute:


```python
g = nx.from_numpy_array(mat==1, create_using=nx.DiGraph, weight_attribute="w")
print(g.edges(data=True))
```

```
[(0, 1, {}), (1, 0, {})]
```

#### Also seems fine but changes fn signature:

Perhaps better for backwards compat is to allow the user to opt-out of the current behavior with a new argument `weight_attribute` which defaults to `weight` but can be set to `None` to remove this behavior. (Perhaps a bit more predictable than the above option?)

#### Seems kinda jank but is totally back-compat:

Setting `parallel_edges=True` and `create_using=nx.DiGraph` currently doesn't do anything, and could be repurposed to perform this behavior. Eh. This is kinda gross.


Discussion:
This seems like a reasonable request to me. I can certainly see a use-case for simply using the adjacency matrix to specify edges *without* creating edge attributes (e.g. to save memory). It doesn't look like there's an obvious way to do this, so I think this would be a nice enhancement.

Of all the proposals above I think the one I like best is to add a new keyword argument (though with a more generic name... maybe `edge_attribute`?) to indicate the edge attribute name. By default this would be `edge_attribute="weight"` for backwards compatibility, but users could also pass in `None` which would then treat the adjacency matrix as binary only indicating edge existence and not store attributes.

There are some potentially messy corners (e.g. the interpretation of `0` as non-edge and making sure everything works well with structured dtypes) but overall I think this would be an improvement!
I agree with @rossbar that this sounds like a good feature. I'd argue for the keyword to be `weight="weight"` as the default way to refer to edge attributes is `weight` in many other places (like `to_numpy_array`). But I don't feel strongly about it.

And I agree that there might be some messy corner cases.  Distinguishing between 0 and non-edge might be a different PR. But could be done with a keyword argument like `nonedge_sentinal=0` so people could use `nonedge_sentinal=float("nan")` for example.
Lots of thanks for the thoughtful discussion here! 

To roll up some of the points so far, the new signature would look something like this:

```python
def from_numpy_array(
    mat: np.ndarray, 
    edge_attribute: str = "weight",
    nonedge_sentinel: Any = float("nan") # of the same dtype as `mat`
)
```

@dschult — I used @rossbar's `edge_attribute` instead of `weight=weight` (nothing personal <3) purely to clarify that this is the name of the new attribute. For example,

```python
# Create edges of 5 different colors:
mat = np.random.randint(0, 10, (5, 5))

from_numpy_array(mat, weight="color")
```

...feels a bit clunky to me, whereas `edge_attribute="color"` feels a bit more intuitive.

Happy to discuss that some more of course!

If you like, I'm happy to draft up a PR with impl and tests for your review :)
Interesting that all the `to_<datastructure>` functions use `weight="weight"` keywords, while the `from_<data_structure>` functions either don't have it, use `edge_attribute` or use `edge_attr`.  We should probably make this more uniform. The `edge_attr` case is interesting because it uses value `None` for no attributes, just like we are thinking, and `True` to mean load all the available information for each edge.  We could use that option for compound dtypes perhaps. I'm not sure what's involved there.

And just to be clear, the default value for non-edges should be 0. That is standard. We just want to provide a way for people to change that if desired.
Got it — would you prefer one PR that refactors the `from_X` fns and adds this functionality, or two (1. refactor; 2. close this issue)?
Let's keep the scope narrow so things are easier to review. I'd be +1 for a PR to address this feature in `from_numpy_array` specifically!

Resolving patch:
diff --git a/networkx/convert_matrix.py b/networkx/convert_matrix.py
--- a/networkx/convert_matrix.py
+++ b/networkx/convert_matrix.py
@@ -1000,7 +1000,7 @@ def to_numpy_array(
     return A
 
 
-def from_numpy_array(A, parallel_edges=False, create_using=None):
+def from_numpy_array(A, parallel_edges=False, create_using=None, edge_attr="weight"):
     """Returns a graph from a 2D NumPy array.
 
     The 2D NumPy array is interpreted as an adjacency matrix for the graph.
@@ -1020,6 +1020,10 @@ def from_numpy_array(A, parallel_edges=False, create_using=None):
     create_using : NetworkX graph constructor, optional (default=nx.Graph)
        Graph type to create. If graph instance, then cleared before populated.
 
+    edge_attr : String, optional (default="weight")
+        The attribute to which the array values are assigned on each edge. If
+        it is None, edge attributes will not be assigned.
+
     Notes
     -----
     For directed graphs, explicitly mention create_using=nx.DiGraph,
@@ -1034,6 +1038,11 @@ def from_numpy_array(A, parallel_edges=False, create_using=None):
     indicated by the upper triangle of the array `A` will be added to the
     graph.
 
+    If `edge_attr` is Falsy (False or None), edge attributes will not be
+    assigned, and the array data will be treated like a binary mask of
+    edge presence or absence. Otherwise, the attributes will be assigned
+    as follows:
+
     If the NumPy array has a single data type for each array entry it
     will be converted to an appropriate Python data type.
 
@@ -1127,7 +1136,9 @@ def from_numpy_array(A, parallel_edges=False, create_using=None):
                 {
                     name: kind_to_python_type[dtype.kind](val)
                     for (_, dtype, name), val in zip(fields, A[u, v])
-                },
+                }
+                if edge_attr
+                else {},
             )
             for u, v in edges
         )
@@ -1144,11 +1155,17 @@ def from_numpy_array(A, parallel_edges=False, create_using=None):
         #         for d in range(A[u, v]):
         #             G.add_edge(u, v, weight=1)
         #
-        triples = chain(
-            ((u, v, {"weight": 1}) for d in range(A[u, v])) for (u, v) in edges
-        )
+        if edge_attr:
+            triples = chain(
+                ((u, v, {edge_attr: 1}) for d in range(A[u, v])) for (u, v) in edges
+            )
+        else:
+            triples = chain(((u, v, {}) for d in range(A[u, v])) for (u, v) in edges)
     else:  # basic data type
-        triples = ((u, v, {"weight": python_type(A[u, v])}) for u, v in edges)
+        if edge_attr:
+            triples = ((u, v, {edge_attr: python_type(A[u, v])}) for u, v in edges)
+        else:
+            triples = ((u, v, {}) for u, v in edges)
     # If we are creating an undirected multigraph, only add the edges from the
     # upper triangle of the matrix. Otherwise, add all the edges. This relies
     # on the fact that the vertices created in the


Test patch:
diff --git a/networkx/tests/test_convert_numpy.py b/networkx/tests/test_convert_numpy.py
--- a/networkx/tests/test_convert_numpy.py
+++ b/networkx/tests/test_convert_numpy.py
@@ -164,6 +164,34 @@ def test_from_numpy_array_parallel_edges(self):
         )
         assert graphs_equal(actual, expected)
 
+    @pytest.mark.parametrize(
+        "dt",
+        (
+            None,  # default
+            int,  # integer dtype
+            np.dtype(
+                [("weight", "f8"), ("color", "i1")]
+            ),  # Structured dtype with named fields
+        ),
+    )
+    def test_from_numpy_array_no_edge_attr(self, dt):
+        A = np.array([[0, 1], [1, 0]], dtype=dt)
+        G = nx.from_numpy_array(A, edge_attr=None)
+        assert "weight" not in G.edges[0, 1]
+        assert len(G.edges[0, 1]) == 0
+
+    def test_from_numpy_array_multiedge_no_edge_attr(self):
+        A = np.array([[0, 2], [2, 0]])
+        G = nx.from_numpy_array(A, create_using=nx.MultiDiGraph, edge_attr=None)
+        assert all("weight" not in e for _, e in G[0][1].items())
+        assert len(G[0][1][0]) == 0
+
+    def test_from_numpy_array_custom_edge_attr(self):
+        A = np.array([[0, 2], [3, 0]])
+        G = nx.from_numpy_array(A, edge_attr="cost")
+        assert "weight" not in G.edges[0, 1]
+        assert G.edges[0, 1]["cost"] == 3
+
     def test_symmetric(self):
         """Tests that a symmetric array has edges added only once to an
         undirected multigraph when using :func:`networkx.from_numpy_array`.