--- title: "Visualizing models as networks" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Visualizing models as networks} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` ```{r setup, message = FALSE} library(networkformat) library(ggraph) library(tidygraph) ``` **networkformat** converts R objects into graph-ready format for visualization with ggraph. For tree models, the quickest path is: - `as.igraph(x)` --- convert a model to an igraph object - `as_tbl_graph(x)` --- convert to a tidygraph tbl_graph (for use with ggraph) For vectors and data frames, use `edgelist()` and `nodelist()` to build the edge/node data, then pass them to igraph or tidygraph. If you want to work with the raw edgelists and nodelists directly --- filtering edges, computing graph statistics, or building custom igraph objects --- see `vignette("edgelist-nodelist")`. # Decision tree ```{r tree-fit, eval = requireNamespace("tree", quietly = TRUE)} tr <- tree::tree(Species ~ Sepal.Length + Sepal.Width, data = iris) tg <- as_tbl_graph(tr) ``` The graph carries all the attributes you need for plotting --- node labels, split variables, leaf predictions, observation counts, and parsed edge thresholds. ```{r tree-stats, eval = requireNamespace("tree", quietly = TRUE) && requireNamespace("igraph", quietly = TRUE)} cat("Nodes:", igraph::vcount(tg), " Edges:", igraph::ecount(tg), " Depth:", max(igraph::distances(tg, mode = "out")[1, ]), "\n") ``` ```{r tree-plot, eval = requireNamespace("tree", quietly = TRUE) && requireNamespace("ggraph", quietly = TRUE), fig.width = 10, fig.height = 7} # Add a fill column: split variable for internal nodes, predicted class for leaves tg <- tg %>% mutate(fill_var = ifelse(is_leaf, yval, var)) # Build a palette: one color per variable + one per class node_df <- as.data.frame(tg, what = "vertices") vars <- setdiff(unique(node_df$var), "") classes <- unique(node_df$yval[node_df$is_leaf]) var_pal <- c( setNames(c("#3B8EA5", "#D4A373"), vars[seq_len(min(length(vars), 2))]), setNames(c("#66c2a5", "#fc8d62", "#8da0cb"), classes[seq_len(min(length(classes), 3))]) ) ggraph(tg, layout = "tree") + geom_edge_diagonal(colour = "grey60", width = 0.5) + geom_edge_diagonal( aes(label = ifelse(is.na(split_point), "", paste0(split_op, split_point))), angle_calc = "along", label_dodge = unit(3, "mm"), label_size = 2.5, label_colour = "grey30", colour = NA) + geom_node_label( aes(label = label, fill = fill_var), size = 2.5, colour = "white", fontface = "bold", label.padding = unit(0.25, "lines"), label.r = unit(0.2, "lines")) + scale_fill_manual(values = var_pal, name = NULL) + theme_graph(base_family = "sans") + theme(legend.position = "bottom") + labs(title = "Iris classification tree", subtitle = "Split on Sepal.Length and Sepal.Width") ``` Internal nodes are colored by the split variable. Leaf nodes show the predicted class. Edge labels show the split condition (e.g. `<5.45`). # Random forest `as_tbl_graph(rf)` combines all trees into a single graph with disconnected components --- one per tree. Each node carries a `treenum` attribute so you can distinguish them. ```{r rf-fit, eval = requireNamespace("randomForest", quietly = TRUE), message = FALSE} rf <- randomForest::randomForest(Species ~ ., data = iris, ntree = 3, maxnodes = 5) # All 3 trees in one graph tg_rf <- as_tbl_graph(rf) ``` ```{r rf-stats, eval = requireNamespace("randomForest", quietly = TRUE) && requireNamespace("igraph", quietly = TRUE)} cat("Nodes:", igraph::vcount(tg_rf), " Edges:", igraph::ecount(tg_rf), " Components:", igraph::count_components(tg_rf), "\n") ``` ```{r rf-plot, eval = requireNamespace("randomForest", quietly = TRUE) && requireNamespace("ggraph", quietly = TRUE), fig.width = 14, fig.height = 7} # Map integer prediction indices to class names tg_rf <- tg_rf %>% mutate( pred_class = rf$classes[prediction], display_label = ifelse( is_leaf, pred_class, paste0(split_var_name, "\n< ", round(split_point, 1)) ), fill_var = ifelse(is_leaf, pred_class, split_var_name) ) # Palette: one color per predictor variable + one per predicted class node_df <- as.data.frame(tg_rf, what = "vertices") predictor_vars <- sort(unique(na.omit(node_df$split_var_name))) leaf_classes <- sort(unique(na.omit(node_df$pred_class))) var_colors <- c("#3B8EA5", "#D4A373", "#7B68AE", "#E07B54") class_colors <- c("#66c2a5", "#fc8d62", "#8da0cb") var_pal_rf <- c( setNames(var_colors[seq_along(predictor_vars)], predictor_vars), setNames(class_colors[seq_along(leaf_classes)], leaf_classes) ) ggraph(tg_rf, layout = "tree") + geom_edge_link( arrow = arrow(length = unit(1.5, "mm"), type = "closed"), end_cap = circle(5, "mm"), colour = "grey70", width = 0.3) + geom_node_label( aes(label = display_label, fill = fill_var), size = 2.5, colour = "black", fontface = "bold", label.padding = unit(0.2, "lines"), label.r = unit(0.15, "lines"), alpha = 0.85) + scale_fill_manual(values = var_pal_rf, name = NULL) + theme_graph(base_family = "sans") + theme(legend.position = "bottom") + labs(title = "Random forest (3 trees)", subtitle = "Each disconnected component is one tree") ``` All three trees are visible in a single plot. Internal nodes are colored by split variable; leaf nodes are colored by predicted class. ## Single tree from a forest For a closer look at one tree: ```{r rf-single, eval = requireNamespace("randomForest", quietly = TRUE) && requireNamespace("ggraph", quietly = TRUE), fig.width = 8, fig.height = 5} tg1 <- as_tbl_graph(rf, treenum = 1) %>% mutate( pred_class = rf$classes[prediction], display_label = ifelse( is_leaf, pred_class, paste0(split_var_name, "\n< ", round(split_point, 1)) ), fill_var = ifelse(is_leaf, pred_class, split_var_name) ) ggraph(tg1, layout = "dendrogram") + geom_edge_elbow(colour = "grey65", width = 0.4) + geom_node_label( aes(label = display_label, fill = fill_var), colour = "black", size = 2.5, fontface = "bold", label.padding = unit(0.25, "lines"), label.r = unit(0.2, "lines"), label.size = 0.4, alpha = 0.2) + geom_node_text( aes(label = display_label), colour = "black", size = 2.5, fontface = "bold") + scale_fill_manual(values = var_pal_rf, name = NULL) + theme_graph(base_family = "sans") + theme(legend.position = "bottom") + labs(title = "Tree 1 from the random forest") ``` # Course prerequisite network networkformat also handles tabular data. The bundled `courses` dataset contains `r nrow(courses)` courses across `r length(unique(courses$dept))` departments, with prerequisite and crosslist relationships. ```{r courses-net, eval = requireNamespace("igraph", quietly = TRUE) && requireNamespace("ggraph", quietly = TRUE), fig.width = 10, fig.height = 7, message = FALSE} library(igraph) # Prereqs as source so arrows naturally point prereq -> course; # crosslist is undirected, so its direction doesn't matter all_edges <- edgelist(courses, source_cols = c(prereq, prereq2, crosslist), target_cols = course) all_edges$directed <- all_edges$from_col != "crosslist" all_edges <- all_edges[all_edges$directed | all_edges$from < all_edges$to, ] nodes <- nodelist(courses, id_col = course) g <- graph_from_data_frame(all_edges, vertices = nodes) tg_courses <- as_tbl_graph(g) cat("Nodes:", vcount(g), " Edges:", ecount(g), "\n") dept_pal <- c(STAT = "#5B8DB8", MATH = "#E07B54", DATA = "#6BAF6B", CS = "#9B6BB0") ggraph(tg_courses, layout = "stress") + geom_edge_link( aes(filter = directed), arrow = arrow(length = unit(2, "mm"), type = "closed"), end_cap = circle(8, "mm"), width = 0.5, colour = "grey45") + geom_edge_link( aes(filter = !directed), end_cap = circle(6, "mm"), start_cap = circle(6, "mm"), width = 0.8, colour = "#9B59B6", linetype = "dashed") + geom_node_label( aes(label = name, fill = dept), colour = "white", fontface = "bold", size = 3, label.padding = unit(0.3, "lines"), label.r = unit(0.2, "lines")) + scale_fill_manual(values = dept_pal, name = "Department") + theme_graph(base_family = "sans") + theme(legend.position = "bottom") + labs(title = "Course prerequisite network", subtitle = "Arrows point from prerequisite to dependent course | Dashed = crosslisted") ``` For the full data frame workflow --- column selection, `na.rm`, `attr_cols`, `symmetric_cols`, and graph statistics --- see `vignette("edgelist-nodelist")`.