--- title: "Working with edgelists and nodelists" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Working with edgelists and nodelists} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` ```{r setup, message = FALSE} library(networkformat) ``` `edgelist()` and `nodelist()` are the core functions of **networkformat**. They extract network structure from vectors, data frames, lists, and tree models as plain data frames, giving you full control over filtering, transforming, and passing the results to igraph or any other tool. For quick visualization without touching the raw data, see `vignette("visualization")`. # Decision tree ## edgelist() ```{r tree-edges, eval = requireNamespace("tree", quietly = TRUE)} tr <- tree::tree(Species ~ Sepal.Length + Sepal.Width, data = iris) edges <- edgelist(tr) edges ``` Columns: | Column | Description | |--------|-------------| | `from` | Parent node ID (binary heap index) | | `to` | Child node ID (binary heap index) | | `label` | Full split condition (e.g. `"Sepal.Length <5.45"`) | | `split_var` | Variable name | | `split_op` | `"<"` or `">"` (NA for categorical splits) | | `split_point` | Numeric threshold (NA for categorical) | The parsed columns let you filter or restyle without regex: ```{r tree-filter, eval = requireNamespace("tree", quietly = TRUE)} # All edges that split on Sepal.Length edges[edges$split_var == "Sepal.Length", ] # Thresholds above 5 edges[!is.na(edges$split_point) & edges$split_point > 5, ] ``` ## nodelist() ```{r tree-nodes, eval = requireNamespace("tree", quietly = TRUE)} nodes <- nodelist(tr) nodes ``` Columns: | Column | Description | |--------|-------------| | `name` | Node ID (matches edgelist `from`/`to`) | | `var` | Split variable, or `""` | | `n` | Observation count | | `dev` | Deviance | | `yval` | Predicted value | | `is_leaf` | Logical | | `label` | Display label: `"\nn="` or `"\nn="` | The `label` column is ready for plotting --- no manual `ifelse()` needed. ## Pairing edges and nodes for igraph Node IDs line up, so you can pass them straight to `igraph::graph_from_data_frame()`: ```{r tree-igraph, eval = requireNamespace("tree", quietly = TRUE) && requireNamespace("igraph", quietly = TRUE)} library(igraph) g <- graph_from_data_frame(edges, directed = TRUE, vertices = nodes) cat("Nodes:", vcount(g), " Edges:", ecount(g), "\n") ``` Or use `as.igraph(tr)` for the same result in one step. ## Visualizing the edgelist pipeline The full workflow --- `edgelist()` -> `nodelist()` -> `graph_from_data_frame()` -> ggraph --- gives you control at every step. For a complete visualization example, see `vignette("visualization")`. # Random forest ## edgelist() ```{r rf-edges, eval = requireNamespace("randomForest", quietly = TRUE), message = FALSE} rf <- randomForest::randomForest(Species ~ ., data = iris, ntree = 3, maxnodes = 5) # All trees rf_edges <- edgelist(rf) head(rf_edges) ``` Columns: | Column | Description | |--------|-------------| | `from` | Parent node index | | `to` | Child node index | | `split_var` | Numeric variable index | | `split_point` | Split threshold | | `prediction` | Predicted value at child node | | `direction` | Branch direction: `"left"` or `"right"` | | `treenum` | Tree number | | `split_var_name` | Human-readable variable name | ### The `treenum` argument Extract specific trees without subsetting: ```{r rf-treenum, eval = requireNamespace("randomForest", quietly = TRUE)} # Single tree t1 <- edgelist(rf, treenum = 1) nrow(t1) # Trees 1 and 3 t13 <- edgelist(rf, treenum = c(1, 3)) table(t13$treenum) ``` ## nodelist() ```{r rf-nodes, eval = requireNamespace("randomForest", quietly = TRUE)} rf_nodes <- nodelist(rf, treenum = 1) rf_nodes ``` Columns: | Column | Description | |--------|-------------| | `name` | Node ID (matches edgelist `from`/`to`) | | `is_leaf` | Logical | | `split_var` | Numeric variable index (NA for leaves) | | `split_var_name` | Variable name (NA for leaves) | | `split_point` | Split threshold (NA for leaves) | | `prediction` | Predicted value (NA for internal nodes) | | `treenum` | Tree number | | `label` | Variable name for internal nodes, predicted value for leaves | # Data frame ## edgelist() When your data is already tabular, `edgelist()` reshapes it into from-to format. Columns are specified with [tidyselect](https://tidyselect.r-lib.org/reference/language.html) syntax --- bare names, strings, numbers, or helpers like `starts_with()`. ```{r df-basic} courses edgelist(courses, source_cols = course, target_cols = prereq) ``` ### Multiple target columns When you pass multiple `target_cols`, each produces a block of edges. The `to_col` column identifies which relationship each edge came from: ```{r df-multi} edgelist(courses, source_cols = course, target_cols = c(prereq, prereq2, crosslist)) ``` ### `na.rm` --- handling missing values By default, rows where from or to is NA are removed. Set `na.rm = FALSE` to keep them: ```{r df-narm} # Default: NAs removed nrow(edgelist(courses, source_cols = course, target_cols = c(prereq, prereq2, crosslist))) # Keep NAs nrow(edgelist(courses, source_cols = course, target_cols = c(prereq, prereq2, crosslist), na.rm = FALSE)) ``` ### `attr_cols` --- selecting edge attributes By default, all columns not used as source or target are carried along. Use `attr_cols` to select specific columns, or `c()` for none: ```{r df-attrs} # Only from, to, metadata edgelist(courses, source_cols = course, target_cols = prereq, attr_cols = c()) # Specific columns edgelist(courses, source_cols = course, target_cols = prereq, attr_cols = c(dept, credits)) ``` ### `symmetric_cols` --- marking undirected relationships Some target columns represent symmetric relationships (crosslists, co-authorships). Use `symmetric_cols` to add a `directed` column and automatically deduplicate symmetric edges: ```{r df-sym} edgelist(courses, source_cols = course, target_cols = c(prereq, prereq2, crosslist), attr_cols = c(), symmetric_cols = crosslist) ``` Prerequisite edges get `directed = TRUE`; crosslist edges get `directed = FALSE`. Symmetric edges are automatically deduped (only `from <= to` kept). Set `dedupe = FALSE` to preserve both directions. ## nodelist() For data frames, `nodelist()` simply reorders columns so the ID column comes first --- convenient for `igraph::graph_from_data_frame()`: ```{r df-nodelist} nodelist(courses, id_col = course) ``` ## Note on tidygraph tidygraph provides its own `as_tbl_graph.data.frame()` method that auto-detects edge data frames with `from`/`to` columns. Since networkformat already uses `from`/`to`, you can pass an edgelist directly to tidygraph: ```{r tidygraph-note, eval = FALSE} edges <- edgelist(courses, source_cols = course, target_cols = prereq) tg <- tidygraph::as_tbl_graph(edges) ``` networkformat does not add its own `as_tbl_graph.data.frame()` method since tidygraph already covers this case. ## Putting it together: course network For a complete course network visualization, see `vignette("visualization")`. Here we build the graph and compute statistics: ```{r courses-graph, eval = requireNamespace("igraph", quietly = TRUE), message = FALSE} library(igraph) all_edges <- edgelist(courses, source_cols = course, target_cols = c(prereq, prereq2, crosslist), attr_cols = c(), symmetric_cols = crosslist) # Reverse prereq arrows so they point from prereq -> dependent course directed_rows <- all_edges$directed all_edges[directed_rows, c("from", "to")] <- all_edges[directed_rows, c("to", "from")] nodes <- nodelist(courses, id_col = course) g <- graph_from_data_frame(all_edges, vertices = nodes) ``` ### Graph statistics Once the data is in igraph, you can compute standard graph metrics: ```{r courses-stats, eval = requireNamespace("igraph", quietly = TRUE)} cat("Nodes:", vcount(g), "\n") cat("Edges:", ecount(g), "\n") cat("Components:", count_components(g), "\n") # In-degree: how many prerequisites each course has deg_in <- degree(g, mode = "in") cat("\nMost prerequisites:\n") print(sort(deg_in[deg_in > 0], decreasing = TRUE)) # Out-degree: how many courses depend on this one deg_out <- degree(g, mode = "out") cat("\nMost depended-on:\n") print(sort(deg_out[deg_out > 0], decreasing = TRUE)) ``` # Lists Lists produce a recursive parent-child edgelist. Each element becomes a node, and nested lists create deeper edges. Path-style IDs (e.g. `root/a/b`) ensure uniqueness. ## edgelist() ```{r list-edges} edgelist(list(a = list(b = 1, c = 2), d = 3)) ``` Columns: | Column | Description | |--------|-------------| | `from` | Parent node path-style ID | | `to` | Child node path-style ID | | `depth` | Integer depth of the child node (root children are 1) | Unnamed elements use positional indices: ```{r list-unnamed} edgelist(list(1, 2, list(3, 4))) ``` Use `max_depth` to limit depth (root = 0, children = 1, ...): ```{r list-maxdepth} edgelist(list(a = list(b = list(c = 1))), max_depth = 2) ``` ## nodelist() ```{r list-nodes} nodelist(list(a = list(b = 1, c = 2), d = 3)) ``` Columns: | Column | Description | |--------|-------------| | `name` | Path-style node ID | | `depth` | Integer depth (root is 0) | | `type` | Element class (`"numeric"`, `"character"`, `"list"`, etc.) | | `n_children` | Number of direct children (0 for leaves) | | `label` | Element name or positional index | ## S3 object fallthrough S3 objects without a dedicated method (e.g. `lm`) are decomposed as plain lists with a diagnostic message: ```{r list-s3, eval = FALSE} fit <- lm(Sepal.Length ~ Sepal.Width, data = iris) edgelist(fit) #> No edgelist method for class 'lm'; treating as a plain list. ``` # Output reference ## edgelist() | Input class | Columns returned | |-------------|-----------------| | atomic vector | `from`, `to`, [`weight`] | | `list` | `from`, `to`, `depth` | | `data.frame` | `from`, `to`, `from_col`, `to_col`, [`directed`], [`weight`], `` | | `tree` | `from`, `to`, `label`, `split_var`, `split_op`, `split_point` | | `randomForest` | `from`, `to`, `split_var`, `split_point`, `prediction`, `direction`, `treenum`, `split_var_name` | | `rpart` | `from`, `to`, `label`, `split_var`, `split_op`, `split_point` | | `xgb.Booster` | `from`, `to`, `feature`, `split`, `quality`, `cover`, `treenum` | | `gbm` | `from`, `to`, `split_var`, `split_point`, `prediction`, `treenum`, `split_var_name` | > **Note:** rpart uses `<` and `>=` for split operators, while tree uses `<` and `>`. ## nodelist() | Input class | Columns returned | |-------------|-----------------| | atomic vector | `name`, `n` | | `list` | `name`, `depth`, `type`, `n_children`, `label` | | `data.frame` | Reordered input (ID column first) | | `tree` | `name`, `var`, `n`, `dev`, `yval`, `is_leaf`, `label` | | `randomForest` | `name`, `is_leaf`, `split_var`, `split_var_name`, `split_point`, `prediction`, `treenum`, `label` | | `rpart` | `name`, `var`, `n`, `dev`, `yval`, `is_leaf`, `label` | | `xgb.Booster` | `name`, `is_leaf`, `feature`, `split`, `quality`, `cover`, `treenum`, `label` | | `gbm` | `name`, `is_leaf`, `split_var`, `split_var_name`, `split_point`, `prediction`, `treenum`, `label` | ## as.igraph() / as_tbl_graph() | Input class | `treenum` | Returns | |-------------|-----------|---------| | `tree` | --- | single graph | | `randomForest` | `NULL` (default) | single graph (all trees as disconnected components) | | `randomForest` | `1` | single graph (one tree) | | `rpart` | --- | single graph | | `xgb.Booster` | `NULL` (default) | single graph (all trees, globally unique string IDs) | | `xgb.Booster` | `1` | single graph (one tree) | | `gbm` | `NULL` (default) | single graph (all trees as disconnected components) | | `gbm` | `1` | single graph (one tree) |