diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e122a9e..6802bff 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -160,7 +160,8 @@ Two further points of style: - Pick a word narrow enough to own the family. `regularity` is preferred over `similarity` because the latter is broad enough to be overrun later, and because generic similarities (`to_cosine()`, `to_correlation()`) belong to `{manynet}` and are consumed here through `distance =` and `cluster_*()`, so they would never live in this family anyway. - The dispatching function should name the method in its `snet_info()` message by interpolation, e.g. `manynet::snet_info("...using {.fn regularity_{regularity}}.")`. This surfaces the convention to users at run time, and makes it obvious if the argument and the prefix ever drift apart. -One known exception: `node_in_equivalence()`'s `motif =` argument is fed by `node_x_*()` functions rather than `motif_*()` ones. Motifs are one of the four core families above and cannot be renamed to suit this rule, so leave that as it is. +One known exception: `node_in_equivalence()`'s `motif =` argument is fed by `node_x_*()` functions rather than `motif_*()` ones. +Motifs are one of the four core families above and cannot be renamed to suit this rule, so leave that as it is. ### Naming within the membership family @@ -255,6 +256,12 @@ a multiplex, a multilevel and a longitudinal network, and decide each case delib ([R/netrics-utils.R](../R/netrics-utils.R)), which drops to the positive ties and says so. Do not simply drop the attribute: that reads a negative tie as a path of length one, when a negative tie is hostility rather than a channel along which cohesion travels. + Where the measure instead counts a tie however it is signed, as a census does, + call `.to_unsigned()` in the same file, which keeps every tie and reads each by + its magnitude. A tie-level function takes this branch even where it measures + distance, since `.to_positive()` drops ties and a tie measure must hold one + value per tie; `tie_by_betweenness()` is the case, and says so in its + `@section Signed networks:`. - **Multiplex.** Take one layer at a time with `manynet::to_uniplex()`. That drops nodes holding none of the retained ties, so results of different lengths would otherwise be recycled against each other; `uniplex_degree()` diff --git a/DESCRIPTION b/DESCRIPTION index 3137473..3ea744b 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: netrics Title: Many Marks, Measures, Memberships, and Motifs for Networks -Version: 1.0.1 +Version: 1.0.2 Description: Many tools for calculating network, node, or tie marks, measures, motifs and memberships of many different types of networks. Marks identify structural positions, measures quantify network properties, @@ -15,7 +15,7 @@ Encoding: UTF-8 LazyData: true Depends: R (>= 4.1.0), - manynet (>= 2.2.3) + manynet (>= 2.3.1) Imports: dplyr, igraph (>= 2.1.0) diff --git a/NEWS.md b/NEWS.md index 66e6cb0..1dd5ff1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,45 @@ +# netrics 1.0.2 + +## Package + +- Fixed some warning tests related to changes in snet output in manynet +- Raised `{manynet}` floor to 2.3.1, retiring `to_ties()` and `net_waves()` shims + +## Marks + +- Fixed `node_is_fold()` erroring on signed networks (closes #25) +- Fixed `node_is_independent()` and `node_is_core()` erroring on multilevel networks (closes #26) +- Fixed `tie_is_transitive()`, `tie_is_triplet()` and `tie_is_cyclical()` erroring on signed networks (closes #25) +- Fixed `tie_is_max()` and `tie_is_min()` to accept a numeric vector, and to name what they mark +- Fixed `tie_is_path()` to name `from` and `to` where either is missing (closes #26) + +## Measures + +- Fixed distance measures erroring on signed networks to use only positive ties (closes #25) + - `node_by_closeness()`, `node_by_harmonic()`, `node_by_reach()`, `node_by_decay()`, + `node_by_integration()`, `node_by_radiality()`, `node_by_eccentricity()`, `node_by_vitality()`, + `node_by_betweenness()`, `node_by_induced()`, `node_x_path()` + - `net_by_closeness()`, `net_by_betweenness()`, `net_by_connectedness()`, `net_by_reach()`, + `net_by_harmonic()`, `net_by_decay()`, `net_by_integration()` + - `mode_by_closeness()`, `mode_by_betweenness()` +- Fixed eigenvector measures erroring or warning on signed networks, which now read each tie by its magnitude (closes #25) +- Fixed `tie_by_betweenness()` erroring on signed networks, which reads each tie by its magnitude so that every tie keeps a value (closes #25) +- Fixed `net_by_modularity()` erroring on signed networks (closes #25) +- Fixed eigenvector and structural hole measures erroring on multilevel networks, which are now measured whole rather than projected (closes #26) +- Fixed `net_by_core()` and `net_by_factions()` to stop with a clear message on a multilevel network, since a `create_*()` ideal covers one layer (closes #26) +- Fixed `net_by_spatial()` to name a non-numeric attribute (closes #26) +- Added `limit=` to `net_by_strength()` and `net_by_toughness()`, which take a minimum over 2^n subsets and so hang above about twenty nodes or ties (closes #34) +- Reduced run time of fragmentation examples by using `fict_greys` instead of `fict_marvel` + +## Memberships + +- Fixed `node_in_community()` erroring on signed networks, which now considers only `node_in_spinglass()` (closes #25) +- Fixed `node_in_core()` and `node_by_core()` returning two values per node on multilevel networks (closes #26) + +## Methods + +- Fixed `coreness_rich()` reading a multilevel network as a plain two-mode one (closes #26) + # netrics 1.0.1 ## Methods diff --git a/R/mark_nodes.R b/R/mark_nodes.R index b136063..aad99df 100644 --- a/R/mark_nodes.R +++ b/R/mark_nodes.R @@ -16,6 +16,15 @@ #' - `node_is_neighbor()` marks nodes that are neighbours of a given node. #' @template param_data #' @template node_mark +#' @section Signed networks: +#' `node_is_fold()` reads a tie as a distance, and a negative tie is +#' hostility rather than a channel along which cohesion travels, so where the +#' network is signed it considers only the positive ties. +#' @section Multilevel networks: +#' A multilevel network reports itself as two-mode, but holds ties within a +#' mode as well as between them, so it cannot be projected onto one mode. +#' `node_is_independent()` therefore marks a multilevel network whole, as +#' [net_by_independence()] does. NULL #' @rdname mark_nodes @@ -31,7 +40,11 @@ NULL #' @export node_is_independent <- function(.data){ .data <- manynet::expect_nodes(.data) - if(manynet::is_twomode(.data)){ + # A multilevel network reports itself as two-mode, but holds ties within a + # mode as well as between them, so it cannot be projected. Its matrix is + # already square over every node, so it takes the one-mode branch, as + # `net_by_independence()` does. + if(manynet::is_twomode(.data) && !.is_multilevel(.data)){ samp <- igraph::largest_ivs(manynet::to_mode1(.data)) if(manynet::is_labelled(.data)){ out <- manynet::node_names(.data) %in% @@ -94,6 +107,7 @@ node_is_cutpoint <- function(.data){ #' @export node_is_fold <- function(.data){ .data <- manynet::expect_nodes(.data) + .data <- .to_positive(.data) mult_tri <- igraph::count_triangles(.data)>1 tris <- igraph::triangles(.data) tris <- matrix(tris, length(tris)/3, 3, byrow = TRUE) diff --git a/R/mark_ties.R b/R/mark_ties.R index fb6b44f..ee1ebbb 100644 --- a/R/mark_ties.R +++ b/R/mark_ties.R @@ -67,6 +67,11 @@ tie_is_bridge <- function(.data){ #' @export tie_is_path <- function(.data, from, to, all_paths = FALSE){ .data <- manynet::expect_ties(.data) + # a path runs between two named nodes, so neither endpoint has a default + if(missing(from) || missing(to)) + manynet::snet_abort("{.fn tie_is_path} needs both {.arg from} and", + "{.arg to}, the nodes the path runs between,", + "e.g. {.code tie_is_path(.data, from = 1, to = 7)}.") out <- igraph::all_shortest_paths(.data, from = from, to = to, mode = "out")$epath if(all_paths){ @@ -136,6 +141,11 @@ tie_is_reciprocated <- function(.data){ #' @template tie_mark #' @family cohesion #' @name mark_triangles +#' @section Signed networks: +#' These marks ask only whether a two-path exists, as a census does, so a tie +#' counts however it is signed. Where the network is signed, each tie is +#' therefore read by its magnitude, and every tie keeps its place in the +#' returned vector. NULL #' @rdname mark_triangles @@ -166,6 +176,8 @@ tie_is_triangular <- function(.data){ #' @export tie_is_transitive <- function(.data){ .data <- manynet::expect_ties(.data) + # once, outside the loop, since `.to_unsigned()` reports what it did + .data <- .to_unsigned(.data) nodes <- manynet::as_edgelist(manynet::to_unnamed(.data)) out <- vapply(seq_len(manynet::net_ties(.data)), function(x){ igraph::distances(manynet::delete_ties(.data, x), @@ -182,6 +194,7 @@ tie_is_transitive <- function(.data){ #' @export tie_is_triplet <- function(.data){ .data <- manynet::expect_ties(.data) + .data <- .to_unsigned(.data) nodes <- manynet::as_edgelist(manynet::to_unnamed(.data)) trans <- tie_is_transitive(.data) altpath <- unlist(lapply(which(trans), function(x){ @@ -203,6 +216,8 @@ tie_is_triplet <- function(.data){ #' @export tie_is_cyclical <- function(.data){ .data <- manynet::expect_ties(.data) + # once, outside the loop, since `.to_unsigned()` reports what it did + .data <- .to_unsigned(.data) out <- vapply(seq_len(manynet::net_ties(.data)), function(x){ nodes <- manynet::as_edgelist(manynet::to_unnamed(.data))[x,] igraph::distances(manynet::delete_ties(.data, x), @@ -349,12 +364,26 @@ tie_is_random <- function(.data, select = 1){ make_tie_mark(out, .data) } +# These two mark a vector of tie values, not a network. Passing a network +# reaches `as.numeric()`, which reports a list that cannot be coerced rather +# than the argument that was wanted. +.check_tie_measure <- function(tie_measure, fun){ + if(inherits(tie_measure, "tie_measure") || is.numeric(tie_measure)) + return(tie_measure) + manynet::snet_abort("{.fn {fun}} marks a tie measure or a numeric vector,", + "not a {.cls {class(tie_measure)[1]}}.", + "Measure the ties first, e.g.", + "{.code {fun}(tie_by_betweenness(.data))}.") +} + #' @rdname mark_select_tie -#' @param tie_measure An object created by a `tie_` measure. +#' @param tie_measure An object created by a `tie_` measure, +#' or a plain numeric vector holding one value per tie. #' @examples #' tie_is_max(tie_by_betweenness(ison_brandes)) #' @export tie_is_max <- function(tie_measure){ + tie_measure <- .check_tie_measure(tie_measure, "tie_is_max") out <- as.numeric(tie_measure) == max(as.numeric(tie_measure)) class(out) <- c("tie_mark", class(out)) out @@ -365,6 +394,7 @@ tie_is_max <- function(tie_measure){ #' tie_is_min(tie_by_betweenness(ison_brandes)) #' @export tie_is_min <- function(tie_measure){ + tie_measure <- .check_tie_measure(tie_measure, "tie_is_min") out <- as.numeric(tie_measure) == min(as.numeric(tie_measure)) class(out) <- c("tie_mark", class(out)) out diff --git a/R/measure_centrality_between.R b/R/measure_centrality_between.R index dde5007..d400ba5 100644 --- a/R/measure_centrality_between.R +++ b/R/measure_centrality_between.R @@ -38,6 +38,13 @@ #' @template node_measure #' @param cutoff The maximum path length to consider when calculating betweenness. #' If negative or NULL (the default), there's no limit to the path lengths considered. +#' @section Signed networks: +#' These measures read a tie as a distance, and a negative tie is hostility +#' rather than a channel along which cohesion travels. +#' Where the network is signed, they therefore consider only the positive +#' ties, and say so. +#' Use [manynet::to_unsigned()] first to control this yourself. + NULL #' @rdname measure_central_between @@ -75,6 +82,8 @@ node_by_betweenness <- function(.data, normalized = TRUE, cutoff = NULL){ .data <- manynet::expect_nodes(.data) + + .data <- .to_positive(.data) weights <- `if`(manynet::is_weighted(.data), manynet::tie_weights(.data), NA) graph <- manynet::as_igraph(.data) @@ -129,6 +138,7 @@ node_by_betweenness <- function(.data, normalized = TRUE, node_by_induced <- function(.data, normalized = TRUE, cutoff = NULL){ .data <- manynet::expect_nodes(.data) + .data <- .to_positive(.data) endog <- sum(node_by_betweenness(.data, normalized = normalized, cutoff = cutoff), na.rm = TRUE) exog <- vapply(seq.int(manynet::net_nodes(.data)), @@ -220,6 +230,13 @@ node_by_stress <- function(.data, normalized = TRUE){ #' @family betweenness #' @family centrality #' @template tie_measure +#' @section Signed networks: +#' A tie measure holds one value per tie, so `tie_by_betweenness()` cannot +#' drop the negative ties as the distance measures at the node and network +#' level do. It reads each tie by its magnitude instead, which is what +#' [node_by_betweenness()] in effect does for a network whose ties are signed +#' but not weighted. Use [manynet::to_unsigned()] first to control this +#' yourself. NULL #' @rdname measure_central_tie_between @@ -247,6 +264,7 @@ NULL #' @export tie_by_betweenness <- function(.data, normalized = TRUE){ .data <- manynet::expect_ties(.data) + .data <- .to_unsigned(.data) .data <- manynet::as_igraph(.data) eddies <- manynet::as_edgelist(.data) eddies <- paste(eddies[["from"]], eddies[["to"]], sep = "-") @@ -311,6 +329,13 @@ tie_by_betweenness <- function(.data, normalized = TRUE){ #' against the other nodes of its own mode. Since a two-mode incidence #' structure gives these no distinct "out" counterpart, #' `mode_by_betweenness()` accepts only `"all"` and `"in"`. +#' @section Signed networks: +#' These measures read a tie as a distance, and a negative tie is hostility +#' rather than a channel along which cohesion travels. +#' Where the network is signed, they therefore consider only the positive +#' ties, and say so. +#' Use [manynet::to_unsigned()] first to control this yourself. + NULL #' @rdname measure_centralisation_between @@ -319,6 +344,7 @@ NULL #' @export net_by_betweenness <- function(.data, normalized = TRUE) { .data <- manynet::expect_nodes(.data) + .data <- .to_positive(.data) graph <- manynet::as_igraph(.data) if (manynet::is_twomode(.data)) { @@ -346,6 +372,7 @@ net_by_betweenness <- function(.data, normalized = TRUE) { mode_by_betweenness <- function(.data, normalized = TRUE, direction = c("all", "in")) { .data <- manynet::expect_nodes(.data) + .data <- .to_positive(.data) direction <- match.arg(direction) graph <- manynet::as_igraph(.data) diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index d4b35b9..58a2768 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -50,6 +50,13 @@ #' @family closeness #' @family centrality #' @template node_measure +#' @section Signed networks: +#' These measures read a tie as a distance, and a negative tie is hostility +#' rather than a channel along which cohesion travels. +#' Where the network is signed, they therefore consider only the positive +#' ties, and say so. +#' Use [manynet::to_unsigned()] first to control this yourself. + NULL #' @rdname measure_central_close @@ -83,6 +90,8 @@ node_by_closeness <- function(.data, normalized = TRUE, direction = c("out", "in", "all"), cutoff = NULL){ .data <- manynet::expect_nodes(.data) + + .data <- .to_positive(.data) direction <- match.arg(direction) weights <- `if`(manynet::is_weighted(.data), manynet::tie_weights(.data), NA) @@ -141,6 +150,7 @@ node_by_closeness <- function(.data, normalized = TRUE, node_by_harmonic <- function(.data, normalized = TRUE, cutoff = -1, decay = NULL, direction = c("out", "in")){ .data <- manynet::expect_nodes(.data) + .data <- .to_positive(.data) direction <- match.arg(direction) if(is.null(decay)){ out <- igraph::harmonic_centrality(as_igraph(.data), # weighted if present @@ -199,6 +209,7 @@ node_by_harmonic <- function(.data, normalized = TRUE, cutoff = -1, #' @export node_by_reach <- function(.data, normalized = TRUE, cutoff = 2){ .data <- manynet::expect_nodes(.data) + .data <- .to_positive(.data) if(manynet::is_weighted(.data)){ tore <- manynet::as_matrix(.data)/mean(manynet::as_matrix(.data)) out <- 1/tore @@ -273,6 +284,7 @@ node_by_decay <- function(.data, normalized = TRUE, decay = 0.5, node_by_integration <- function(.data, normalized = TRUE, direction = c("in", "out")){ .data <- manynet::expect_nodes(.data) + .data <- .to_positive(.data) direction <- match.arg(direction) dists <- igraph::distances(manynet::as_igraph(.data), mode = ifelse(direction == "in", "in", "out")) @@ -369,6 +381,7 @@ node_by_information <- function(.data, normalized = TRUE){ #' @export node_by_eccentricity <- function(.data, normalized = TRUE){ .data <- manynet::expect_nodes(.data) + .data <- .to_positive(.data) if(!manynet::is_connected(.data)) manynet::snet_unavailable("Eccentricity centrality is only available for connected networks.") disties <- igraph::distances(as_igraph(.data)) @@ -446,6 +459,7 @@ node_by_distance <- function(.data, from, to, normalized = TRUE){ #' @export node_by_vitality <- function(.data, normalized = TRUE){ .data <- manynet::expect_nodes(.data) + .data <- .to_positive(.data) .data <- manynet::as_igraph(.data) out <- vapply(manynet::snet_progress_nodes(.data), function(x){ sum(igraph::distances(.data)) - @@ -571,7 +585,7 @@ NULL #' @export tie_by_closeness <- function(.data, normalized = TRUE){ .data <- manynet::expect_ties(.data) - edge_adj <- .to_linegraph(.data) + edge_adj <- manynet::to_linegraph(.data) out <- node_by_closeness(edge_adj, normalized = normalized) class(out) <- "numeric" make_tie_measure(out, .data, measure = "closeness centrality", @@ -620,6 +634,13 @@ tie_by_closeness <- function(.data, normalized = TRUE){ #' `net_by_*()` functions return a `network_measure` scalar; #' `mode_by_closeness()` returns a `mode_measure` numeric vector of length two, #' giving one centralization score per mode. +#' @section Signed networks: +#' These measures read a tie as a distance, and a negative tie is hostility +#' rather than a channel along which cohesion travels. +#' Where the network is signed, they therefore consider only the positive +#' ties, and say so. +#' Use [manynet::to_unsigned()] first to control this yourself. + NULL #' @rdname measure_centralisation_close @@ -630,6 +651,8 @@ net_by_closeness <- function(.data, normalized = TRUE, direction = c("all", "out", "in")){ .data <- manynet::expect_nodes(.data) + + .data <- .to_positive(.data) direction <- match.arg(direction) graph <- manynet::as_igraph(.data) @@ -660,6 +683,8 @@ mode_by_closeness <- function(.data, normalized = TRUE, direction = c("all", "out", "in")){ .data <- manynet::expect_nodes(.data) + + .data <- .to_positive(.data) direction <- match.arg(direction) graph <- manynet::as_igraph(.data) diff --git a/R/measure_centrality_degree.R b/R/measure_centrality_degree.R index e07e9a8..71bdb5f 100644 --- a/R/measure_centrality_degree.R +++ b/R/measure_centrality_degree.R @@ -260,7 +260,7 @@ NULL #' @export tie_by_degree <- function(.data, normalized = TRUE){ .data <- manynet::expect_ties(.data) - edge_adj <- .to_linegraph(.data) + edge_adj <- manynet::to_linegraph(.data) out <- node_by_degree(edge_adj, normalized = normalized) class(out) <- "numeric" make_tie_measure(out, .data, measure = "degree centrality", diff --git a/R/measure_centrality_eigen.R b/R/measure_centrality_eigen.R index 57e9acb..a3c9051 100644 --- a/R/measure_centrality_eigen.R +++ b/R/measure_centrality_eigen.R @@ -44,6 +44,17 @@ #' @family eigenvector #' @family centrality #' @template node_measure +#' @section Signed networks: +#' These measures do not read a tie as a distance, so where the network is +#' signed each tie is read by its magnitude rather than the negative ties +#' being dropped. Use [manynet::to_unsigned()] first to control this +#' yourself. +#' @section Multilevel networks: +#' A multilevel network reports itself as two-mode, but holds ties within a +#' mode as well as between them, so it cannot be projected onto one mode. +#' These measures therefore score a multilevel network whole, as +#' [net_by_independence()] does. The projection remains for genuine two-mode +#' networks, where no two nodes of one mode are ever tied. NULL #' @rdname measure_central_eigen @@ -82,6 +93,8 @@ node_by_eigenvector <- function(.data, normalized = TRUE, scaled = TRUE, scale = NULL){ .data <- manynet::expect_nodes(.data) + + .data <- .to_unsigned(.data) scaled <- resolve_scaled(scaled, scale) weights <- `if`(manynet::is_weighted(.data), manynet::tie_weights(.data), NULL) @@ -97,8 +110,12 @@ node_by_eigenvector <- function(.data, normalized = TRUE, scaled = TRUE, if(!manynet::is_connected(.data)) manynet::snet_warn("Unconnected networks will only allow nodes from one component to have non-zero eigenvector scores.") - # Do the calculations - if (!manynet::is_twomode(graph)){ + # Do the calculations. + # A multilevel network reports itself as two-mode, but holds ties within a + # mode as well as between them, so it cannot be projected. It needs no + # projection either: its matrix is already square over every node, so it + # takes the one-mode branch, as `net_by_independence()` does. + if (!manynet::is_twomode(graph) || .is_multilevel(graph)){ out <- igraph::eigen_centrality(graph = graph, directed = manynet::is_directed(graph), weights = weights, @@ -150,6 +167,8 @@ node_by_power <- function(.data, normalized = TRUE, scaled = FALSE, scale = NULL, exponent = 1){ .data <- manynet::expect_nodes(.data) + + .data <- .to_unsigned(.data) scaled <- resolve_scaled(scaled, scale) graph <- manynet::as_igraph(.data) @@ -163,8 +182,9 @@ node_by_power <- function(.data, normalized = TRUE, scaled = FALSE, exponent <- 0 } - # Do the calculations - if (!manynet::is_twomode(graph)){ + # Do the calculations. A multilevel network takes the one-mode branch, + # since it cannot be projected; see `node_by_eigenvector()`. + if (!manynet::is_twomode(graph) || .is_multilevel(graph)){ out <- igraph::power_centrality(graph = graph, exponent = exponent, rescale = scaled) @@ -233,6 +253,7 @@ node_by_power <- function(.data, normalized = TRUE, scaled = FALSE, #' @export node_by_alpha <- function(.data, decay = 0.85, alpha = NULL){ .data <- manynet::expect_nodes(.data) + .data <- .to_unsigned(.data) decay <- check_decay(resolve_decay(decay, alpha, "alpha")) # Alpha centrality is unbounded and can be negative, so there is no # theoretical maximum to normalise against. @@ -264,6 +285,7 @@ node_by_alpha <- function(.data, decay = 0.85, alpha = NULL){ #' @export node_by_pagerank <- function(.data, decay = 0.85){ .data <- manynet::expect_nodes(.data) + .data <- .to_unsigned(.data) decay <- check_decay(decay) # PageRank is a stationary distribution over a random walk, so scores are # already shares summing to one and no further rescaling applies. @@ -289,6 +311,7 @@ node_by_pagerank <- function(.data, decay = 0.85){ #' @export node_by_authority <- function(.data, scaled = TRUE){ .data <- manynet::expect_nodes(.data) + .data <- .to_unsigned(.data) out <- igraph::hits_scores(manynet::as_igraph(.data), scale = scaled)$authority make_node_measure(out, .data, measure = "authority centrality", range = `if`(scaled, c(0, 1), c(0, Inf)), @@ -299,6 +322,7 @@ node_by_authority <- function(.data, scaled = TRUE){ #' @export node_by_hub <- function(.data, scaled = TRUE){ .data <- manynet::expect_nodes(.data) + .data <- .to_unsigned(.data) out <- igraph::hits_scores(manynet::as_igraph(.data), scale = scaled)$hub make_node_measure(out, .data, measure = "hub centrality", range = `if`(scaled, c(0, 1), c(0, Inf)), @@ -357,6 +381,7 @@ node_by_subgraph <- function(.data, decay = 1, method = NULL){ walks <- resolve_method(walks, method, "walks") .data <- manynet::expect_nodes(.data) + .data <- .to_unsigned(.data) walks <- match.arg(walks, c("all", "odd", "even")) decay <- check_decay(decay) out <- .closed_walks(.data, decay, walks) @@ -456,7 +481,7 @@ NULL #' @export tie_by_eigenvector <- function(.data, normalized = TRUE){ .data <- manynet::expect_ties(.data) - edge_adj <- .to_linegraph(.data) + edge_adj <- manynet::to_linegraph(.data) out <- node_by_eigenvector(edge_adj, normalized = normalized) class(out) <- "numeric" make_tie_measure(out, .data, measure = "eigenvector centrality", @@ -506,6 +531,7 @@ NULL #' @export net_by_eigenvector <- function(.data, normalized = TRUE){ .data <- manynet::expect_nodes(.data) + .data <- .to_unsigned(.data) if (manynet::is_twomode(.data)) { # Two-mode eigenvector centralization is intrinsically per mode # (see `mode_by_eigenvector()`, following Borgatti and Everett, 1997). @@ -530,6 +556,7 @@ net_by_eigenvector <- function(.data, normalized = TRUE){ #' @export mode_by_eigenvector <- function(.data, normalized = TRUE){ .data <- manynet::expect_nodes(.data) + .data <- .to_unsigned(.data) if (!manynet::is_twomode(.data)) manynet::snet_abort("`mode_by_eigenvector()` is only defined for two-mode networks; use `net_by_eigenvector()` for one-mode networks.") out <- c("Mode 1" = igraph::centr_eigen(manynet::as_igraph(manynet::to_mode1(.data)), diff --git a/R/measure_change.R b/R/measure_change.R index 630e21b..1e798fb 100644 --- a/R/measure_change.R +++ b/R/measure_change.R @@ -17,9 +17,9 @@ NULL net_by_waves <- function(.data){ .data <- manynet::expect_nodes(.data) # A longitudinal network holds its waves in a `wave` or a `time` tie - # attribute, so reading only `wave` reported one wave for e.g. `ison_monks`. - # `.net_waves()` covers both, and a changing network counts its changelist. - tie_waves <- .net_waves(.data) + # attribute, and `manynet::net_waves()` reads both since manynet 2.3.0. + # A changing network counts its changelist instead. + tie_waves <- manynet::net_waves(.data) if(manynet::is_changing(.data)){ chltime <- manynet::as_changelist(.data)$time chg_waves <- (max(chltime)+1) - max(min(chltime)-1, 0) diff --git a/R/measure_cohesion.R b/R/measure_cohesion.R index 79ea6ab..13a3f80 100644 --- a/R/measure_cohesion.R +++ b/R/measure_cohesion.R @@ -223,6 +223,18 @@ net_by_length <- function(.data){ #' @template param_data #' @family cohesion #' @template net_measure +#' @param limit The largest network `net_by_strength()` and +#' `net_by_toughness()` will enumerate: the number of ties for the first and +#' of nodes for the second. By default 20. Above this each stops rather than +#' running for hours. Raise it to measure a larger network anyway. +#' @section Cost of the enumerated measures: +#' `net_by_cohesion()` and `net_by_adhesion()` are connectivity problems that +#' `{igraph}` solves directly, so they run on a network of any size. +#' `net_by_strength()` and `net_by_toughness()` instead take a minimum over +#' every subset of the tieset or the nodeset, which is \eqn{2^n} subsets. +#' Their cost therefore quadruples for every two nodes added: a ring of 16 +#' nodes takes about 8 seconds, one of 20 over two minutes, and one of 30 +#' more than a day. `limit` stops each before it becomes a hang. NULL #' @rdname measure_fragmentation @@ -234,8 +246,8 @@ NULL #' _Sociological Methodology_ 31(1): 305-59. #' \doi{10.1111/0081-1750.00098} #' @examples -#' net_by_cohesion(fict_marvel) -#' net_by_cohesion(to_giant(fict_marvel)) +#' net_by_cohesion(fict_greys) +#' net_by_cohesion(to_giant(fict_greys)) #' @export net_by_cohesion <- function(.data){ .data <- manynet::expect_nodes(.data) @@ -248,8 +260,8 @@ net_by_cohesion <- function(.data){ #' @rdname measure_fragmentation #' @importFrom igraph adhesion #' @examples -#' net_by_adhesion(fict_marvel) -#' net_by_adhesion(to_giant(fict_marvel)) +#' net_by_adhesion(fict_greys) +#' net_by_adhesion(to_giant(fict_greys)) #' @export net_by_adhesion <- function(.data){ .data <- manynet::expect_nodes(.data) @@ -259,13 +271,31 @@ net_by_adhesion <- function(.data){ normalization = "none") } +# Both of these take a minimum over every subset of the tieset or the nodeset, +# which is 2^n subsets, each of which is then counted for components. The run +# time quadruples for every two nodes added: a ring of 16 takes 8 seconds, one +# of 20 takes over two minutes, and one of 30 would take more than a day, while +# the subset list itself fails to allocate around 96. A user therefore meets a +# hang long before an error, so the size is checked first and named. +# See https://github.com/stocnet/netrics/issues/34. +.check_enumerable <- function(n, limit, fun, unit){ + if(n <= limit) return(invisible(n)) + manynet::snet_abort("{.fn {fun}} takes a minimum over every subset of the", + "{unit}, which doubles in cost with each one added.", + "This network has {n} {unit}, above the {.arg limit} of", + "{limit}, so the measure would not finish. Raise", + "{.arg limit} to try anyway, or measure a smaller", + "network, e.g. with {.fn to_giant} or {.fn to_ego}.") +} + #' @rdname measure_fragmentation #' @examples #' net_by_strength(ison_adolescents) #' @export -net_by_strength <- function(.data){ +net_by_strength <- function(.data, limit = 20){ .data <- manynet::expect_nodes(.data) n <- manynet::net_ties(.data) + .check_enumerable(n, limit, "net_by_strength", "ties") seties <- unlist(lapply(1:n, utils::combn, x = 1:n, simplify = FALSE), recursive = FALSE) out <- vapply(seties, function(x) length(x)/net_by_components(manynet::delete_ties(.data, x)), FUN.VALUE = numeric(1)) @@ -278,9 +308,10 @@ net_by_strength <- function(.data){ #' @examples #' net_by_toughness(ison_adolescents) #' @export -net_by_toughness <- function(.data){ +net_by_toughness <- function(.data, limit = 20){ .data <- manynet::expect_nodes(.data) n <- manynet::net_nodes(.data) + .check_enumerable(n, limit, "net_by_toughness", "nodes") seties <- unlist(lapply(1:n, utils::combn, x = 1:n, simplify = FALSE), recursive = FALSE) out <- vapply(seties, function(x) length(x)/net_by_components(manynet::delete_nodes(.data, x)), FUN.VALUE = numeric(1)) diff --git a/R/measure_features.R b/R/measure_features.R index 57c538c..be51f18 100644 --- a/R/measure_features.R +++ b/R/measure_features.R @@ -388,6 +388,16 @@ net_by_balance <- function(.data) { #' @template param_memb #' @family features #' @template net_measure +#' @section Signed networks: +#' `net_by_modularity()` counts a tie however it is signed, as a census does, +#' so where the network is signed each tie is read by its magnitude. +#' @section Multilevel networks: +#' `net_by_core()` and `net_by_factions()` fit the network to an ideal built +#' by a `manynet::create_*()` function, and those build one layer at a time. +#' A multilevel network holds two, so there is no single ideal to fit it to +#' and both stop rather than compare unlike shapes. +#' Take one layer first, e.g. with [manynet::to_mode1()] or +#' [manynet::to_uniplex()]. NULL #' @rdname measure_fit @@ -436,6 +446,15 @@ net_by_core <- function(.data, variant <- resolve_method(variant, method, "variant") .data <- manynet::expect_nodes(.data) direction <- match.arg(direction) + # `manynet::create_core()` builds one layer, so it reads a multilevel network + # as the two-mode network that network reports itself to be, and returns a + # rectangular ideal that the square observed matrix cannot be compared with. + # What ideal a multilevel network should be fitted to is an open question. + if(.is_multilevel(.data)) + manynet::snet_abort("{.fn net_by_core} fits the network to a", + "core-periphery ideal of one layer, and this network", + "holds two. Take one layer first, e.g. with", + "{.fn to_mode1} or {.fn to_uniplex}.") if(is.null(mark)) mark <- node_is_core(.data, coreness = coreness, direction = direction) @@ -499,6 +518,12 @@ net_by_core <- function(.data, net_by_factions <- function(.data, membership = NULL){ .data <- manynet::expect_nodes(.data) + # `manynet::create_components()` builds one layer; see `net_by_core()` + if(.is_multilevel(.data)) + manynet::snet_abort("{.fn net_by_factions} fits the network to a", + "factional ideal of one layer, and this network holds", + "two. Take one layer first, e.g. with", + "{.fn to_mode1} or {.fn to_uniplex}.") membership <- .resolve_membership(.data, membership) if(is.null(membership)){ manynet::snet_info("No membership vector assigned.", @@ -561,6 +586,8 @@ net_by_modularity <- function(.data, membership = NULL, resolution = 1){ .data <- manynet::expect_nodes(.data) + # modularity counts a tie however it is signed, as a census does + .data <- .to_unsigned(.data) membership <- .resolve_membership(.data, membership) if(is.null(membership)){ manynet::snet_info("Since no membership argument has been provided,", diff --git a/R/measure_heterogeneity.R b/R/measure_heterogeneity.R index a2d612c..0f51413 100644 --- a/R/measure_heterogeneity.R +++ b/R/measure_heterogeneity.R @@ -515,7 +515,12 @@ net_by_spatial <- function(.data, attribute){ .data <- manynet::expect_nodes(.data) N <- manynet::net_nodes(.data) x <- manynet::node_attribute(.data, attribute) - stopifnot(is.numeric(x)) + # Moran's I is the correlation of a value with itself across ties, so the + # attribute has to hold a quantity rather than a category + if(!is.numeric(x)) + manynet::snet_abort("{.fn net_by_spatial} measures the autocorrelation of", + "a numeric attribute, but {.val {attribute}} is", + "{.cls {class(x)[1]}}.") x_bar <- mean(x, na.rm = TRUE) w <- manynet::as_matrix(.data) W <- sum(w, na.rm = TRUE) diff --git a/R/measure_hierarchy.R b/R/measure_hierarchy.R index de959e8..c7359f1 100644 --- a/R/measure_hierarchy.R +++ b/R/measure_hierarchy.R @@ -78,12 +78,20 @@ net_x_hierarchy <- function(.data){ #' 1 - net_by_reciprocity(ison_networkers) #' net_by_efficiency(ison_networkers) #' net_by_upperbound(ison_networkers) +#' @section Signed networks: +#' These measures read a tie as a distance, and a negative tie is hostility +#' rather than a channel along which cohesion travels. +#' Where the network is signed, they therefore consider only the positive +#' ties, and say so. +#' Use [manynet::to_unsigned()] first to control this yourself. + NULL #' @rdname measure_hierarchy #' @export net_by_connectedness <- function(.data){ .data <- manynet::expect_nodes(.data) + .data <- .to_positive(.data) dists <- igraph::distances(manynet::as_igraph(.data)) make_network_measure(1 - sum(dists==Inf)/sum(dists!=0), .data, diff --git a/R/measure_holes.R b/R/measure_holes.R index b09f6d7..eea295d 100644 --- a/R/measure_holes.R +++ b/R/measure_holes.R @@ -38,6 +38,12 @@ #' @template param_data #' @family brokerage #' @template node_measure +#' @section Multilevel networks: +#' A multilevel network reports itself as two-mode, but holds ties within a +#' mode as well as between them, so it cannot be projected onto one mode. +#' `node_by_effsize()` and `node_by_efficiency()` therefore measure a +#' multilevel network whole, as [net_by_independence()] does. The projection +#' remains for genuine two-mode networks. NULL #' @rdname measure_broker_node @@ -112,7 +118,11 @@ node_by_redundancy <- function(.data){ #' @export node_by_effsize <- function(.data){ .data <- manynet::expect_nodes(.data) - if(manynet::is_twomode(.data)){ + # A multilevel network reports itself as two-mode, but holds ties within a + # mode as well as between them, so it cannot be projected. Its matrix is + # already square over every node, so it takes the one-mode branch, as + # `net_by_independence()` does. + if(manynet::is_twomode(.data) && !.is_multilevel(.data)){ mat <- manynet::as_matrix(.data) out <- c(rowSums(manynet::as_matrix(manynet::to_mode1(.data))>0), rowSums(manynet::as_matrix(manynet::to_mode2(.data))>0)) - node_by_redundancy(.data) diff --git a/R/member_community.R b/R/member_community.R index b9ca101..3d76b10 100644 --- a/R/member_community.R +++ b/R/member_community.R @@ -176,12 +176,28 @@ poss_algs <- function(k, .data){ if(!manynet::is_connected(.data)) poss <- exclude(poss, c("node_in_spinglass", "node_in_fluid"), "network unconnected") + # Every algorithm but spinglass reads a negative weight as an error, and + # spinglass reads a sign as a sign, so a signed network leaves it alone. + if(manynet::is_signed(.data)) + poss <- exclude(poss, setdiff(poss, "node_in_spinglass"), + "network signed and only {.fn node_in_spinglass} reads signs") if(manynet::is_directed(.data)) poss <- exclude(poss, c("node_in_louvain", "node_in_leiden", "node_in_labels", "node_in_partition", "node_in_eigen"), "network directed") + # The exclusions can empty the list: a signed network keeps only spinglass, + # which an unconnected network then drops, and the `k` list holds no + # spinglass at all. + if(length(poss) == 0) + manynet::snet_abort("No available algorithm can partition this network.", + `if`(manynet::is_signed(.data), + paste("Only {.fn node_in_spinglass} reads signs,", + "and it needs a connected network and no", + "{.arg k}. Try {.fn to_giant}, or", + "{.fn to_unsigned} to set the signs aside."), + "Try {.fn to_giant}.")) poss } @@ -276,6 +292,13 @@ consensus_memb <- function(.data, k, max_k, times, threshold = 0.5, iter = 10){ #' "Ensemble-based Community Detection in Multilayer Networks". #' _Data Mining and Knowledge Discovery_ 31: 1506-1543. #' \doi{10.1007/s10618-017-0528-8} +#' @section Signed networks: +#' Every algorithm but [node_in_spinglass()] reads a negative weight as an +#' error, and spinglass reads a sign as a sign (Traag and Bruggeman 2009). +#' `node_in_community()` therefore considers only spinglass where the network +#' is signed. Since spinglass needs a connected network and accepts no `k`, +#' a signed network that is unconnected, or a `k` that is given, leaves no +#' applicable algorithm and the function stops. NULL #' @rdname member_community @@ -287,7 +310,9 @@ node_in_community <- function(.data, k = NULL, max_k = 8L, max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) k <- check_k(k, .data) - if(is.null(k) && manynet::net_nodes(.data)<100){ + # `node_in_optimal()` aborts on a negative weight, as every algorithm but + # spinglass does, so a signed network never takes this short cut. + if(is.null(k) && manynet::net_nodes(.data)<100 && !manynet::is_signed(.data)){ # don't use node_in_betweenness because slow and poorer quality to optimal if(consensus) manynet::snet_info("Ignoring {.arg consensus} because {.fn node_in_optimal}", diff --git a/R/method_coreness.R b/R/method_coreness.R index ff1413a..c12c88f 100644 --- a/R/method_coreness.R +++ b/R/method_coreness.R @@ -64,15 +64,31 @@ #' _Proceedings of the Royal Society A_ 476(2241): 20190783. #' \doi{10.1098/rspa.2019.0783} #' @family methods +#' @section Multilevel networks: +#' A multilevel network reports itself as two-mode, but holds ties within a +#' mode as well as between them, so `manynet::as_matrix()` returns one square +#' matrix over every node rather than a rectangular incidence matrix. +#' These methods therefore read a multilevel network as a one-mode one, which +#' is the shape its matrix already has. NULL +# A multilevel network reports itself as two-mode, but holds ties within a mode +# as well as between them, so `manynet::as_matrix()` returns one square matrix +# over every node rather than the rectangular incidence matrix of a plain +# two-mode network. The methods below walk the two shapes differently, so they +# test for a plain two-mode network rather than for a two-mode one, as +# `net_by_independence()` does. +.core_twomode <- function(.data){ + manynet::is_twomode(.data) && !.is_multilevel(.data) +} + # Every method needs the network as a matrix, oriented by `direction`. # "out" leaves the matrix as it is, so rows are senders; "in" transposes it, # so rows are receivers; "all" adds the two, so that a tie in either direction # counts. A two-mode network has no direction to read, so it is left alone. .core_matrix <- function(.data, direction = "all"){ mat <- manynet::as_matrix(.data) - if(manynet::is_twomode(.data) || !manynet::is_directed(.data)) return(mat) + if(.core_twomode(.data) || !manynet::is_directed(.data)) return(mat) switch(direction, out = mat, `in` = t(mat), @@ -179,7 +195,7 @@ coreness_correlation <- function(.data, direction = c("all","out","in"), starts = 5L){ .data <- manynet::expect_nodes(.data) direction <- match.arg(direction) - if(manynet::is_twomode(.data)) + if(.core_twomode(.data)) manynet::snet_abort("{.fn coreness_correlation} compares the network", "against a square ideal, which a two-mode network is", "not. Try {.fn coreness_rich} instead.") @@ -242,7 +258,7 @@ coreness_correlation <- function(.data, direction = c("all","out","in"), coreness_rich <- function(.data, direction = c("all","out","in")){ .data <- manynet::expect_nodes(.data) direction <- match.arg(direction) - twomode <- manynet::is_twomode(.data) + twomode <- .core_twomode(.data) mat <- .core_matrix(.data, direction) stren <- .core_strength(mat, twomode) n <- length(stren) @@ -292,7 +308,7 @@ coreness_transition <- function(.data, direction = c("all","out","in"), beta = seq(0.2, 0.8, 0.2)){ .data <- manynet::expect_nodes(.data) direction <- match.arg(direction) - if(manynet::is_twomode(.data)) + if(.core_twomode(.data)) manynet::snet_abort("{.fn coreness_transition} compares the network", "against a square ideal, which a two-mode network is", "not. Try {.fn coreness_rich} instead.") diff --git a/R/motif_census.R b/R/motif_census.R index e3bffb9..e728a29 100644 --- a/R/motif_census.R +++ b/R/motif_census.R @@ -20,6 +20,12 @@ #' @template param_data #' @template node_motif #' @importFrom igraph vcount make_ego_graph delete_vertices triad_census +#' @section Signed networks: +#' `node_x_path()` reads a tie as a distance, and a negative tie is +#' hostility rather than a channel along which cohesion travels. +#' Where the network is signed, it therefore considers only the positive +#' ties. Use [manynet::to_unsigned()] first to control this yourself. + NULL #' @rdname motif_path @@ -119,6 +125,7 @@ node_x_tie <- function(.data){ #' @export node_x_path <- function(.data){ .data <- manynet::expect_nodes(.data) + .data <- .to_positive(.data) if(manynet::is_weighted(.data)){ tore <- manynet::as_matrix(.data)/mean(manynet::as_matrix(.data)) out <- 1/tore diff --git a/R/netrics-utils.R b/R/netrics-utils.R index d209ac8..2aaa6b0 100644 --- a/R/netrics-utils.R +++ b/R/netrics-utils.R @@ -40,33 +40,6 @@ seq_nodes <- function(.data){ seq.int(manynet::net_nodes(.data)) } -# Compatibility shim: manynet renamed `to_ties()` to `to_linegraph()` in 2.3.0. -# The name is resolved at call time, so this uses `to_linegraph()` where it is -# available and never raises the deprecation warning that `to_ties()` gives -# there. Remove this and call `manynet::to_linegraph()` directly once manynet -# 2.3.x is on CRAN and the DESCRIPTION floor is raised again. -.to_linegraph <- function(.data) { - ns <- asNamespace("manynet") - fn <- if (is.null(ns$to_linegraph)) ns$to_ties else ns$to_linegraph - fn(.data) -} - -# Compatibility shim: `manynet::net_waves()` has existed since manynet 2.2.0, -# but only learned to read a `time` tie attribute in 2.3.0, and every bundled -# longitudinal network holds its waves there rather than under `wave`. At the -# declared floor it therefore reports one wave for `ison_monks`, which -# `manynet::net_waves()` on 2.3.1 reports as three. The count is taken here as -# well, so the answer does not depend on which manynet is installed. -# Remove this and call `manynet::net_waves()` directly once the DESCRIPTION -# floor is raised past 2.3.0. -.net_waves <- function(.data) { - attr_waves <- vapply(c("wave", "time"), function(a) { - vals <- manynet::tie_attribute(.data, a) - if(is.null(vals)) 1L else length(unique(vals)) - }, FUN.VALUE = integer(1)) - max(manynet::net_waves(.data), attr_waves) -} - # Resolve membership to a vector: # if a single character string naming a network attribute is provided, # retrieve that attribute as a vector; otherwise return the value as-is. @@ -138,6 +111,40 @@ seq_nodes <- function(.data){ } else .data } +# The other half of the signed treatment. Where a measure counts a tie however +# it is signed, as a census does, every non-zero entry is a tie and the sign +# carries nothing: `igraph::triad_census()` reads a signed network this way, +# and `.mixed_census()` makes the same reading explicit. This keeps every tie, +# so a tie-level result still holds one value per tie, which `.to_positive()` +# would not. +# +# TODO: `keep = "both"` arrived in manynet 2.3.2, but the DESCRIPTION floor is +# 2.3.1, which is what CRAN serves and what the CI checks run against. The +# fallback takes each weight's magnitude instead, which is the same operation. +# Remove the fallback and call `manynet::to_unsigned(keep = "both")` directly +# once the floor is raised past 2.3.2. +.to_unsigned <- function(.data){ + if(manynet::is_signed(.data)){ + manynet::snet_info("Reading each tie by its magnitude,", + "since a tie counts here however it is signed.") + if("both" %in% eval(formals(manynet::to_unsigned)$keep)) + manynet::to_unsigned(.data, keep = "both") + else { + # The fallback has to return the class it was given, as + # `manynet::to_unsigned()` does, since the measure that called this + # passes the result on to its `make_*()` constructor. A sign is held + # either in a `sign` attribute or as the sign of a weight, so both are + # covered. + out <- .data + if("sign" %in% manynet::net_tie_attributes(out)) + out <- manynet::mutate_ties(out, sign = NULL) + if("weight" %in% manynet::net_tie_attributes(out)) + out <- manynet::mutate_ties(out, weight = abs(weight)) + out + } + } else .data +} + # `manynet::is_multilevel()` is not exported by every 'manynet' version that # this package supports, so the test is kept here. A multilevel network reports # itself as two-mode, but interlocks its levels: it has ties both within and diff --git a/man/mark_nodes.Rd b/man/mark_nodes.Rd index da64a22..f5d0b08 100644 --- a/man/mark_nodes.Rd +++ b/man/mark_nodes.Rd @@ -59,6 +59,21 @@ triangles that are only connected by that node. \item \code{node_is_neighbor()} marks nodes that are neighbours of a given node. } } +\section{Signed networks}{ + +\code{node_is_fold()} reads a tie as a distance, and a negative tie is +hostility rather than a channel along which cohesion travels, so where the +network is signed it considers only the positive ties. +} + +\section{Multilevel networks}{ + +A multilevel network reports itself as two-mode, but holds ties within a +mode as well as between them, so it cannot be projected onto one mode. +\code{node_is_independent()} therefore marks a multilevel network whole, as +\code{\link[=net_by_independence]{net_by_independence()}} does. +} + \examples{ node_is_independent(ison_adolescents) node_is_cutpoint(ison_brandes) diff --git a/man/mark_select_tie.Rd b/man/mark_select_tie.Rd index f645883..f4484cf 100644 --- a/man/mark_select_tie.Rd +++ b/man/mark_select_tie.Rd @@ -20,7 +20,8 @@ For more information on possible coercions, see e.g. \code{\link[manynet:as_stoc \item{select}{Number of elements to select (as TRUE).} -\item{tie_measure}{An object created by a \code{tie_} measure.} +\item{tie_measure}{An object created by a \code{tie_} measure, +or a plain numeric vector holding one value per tie.} } \value{ A \code{tie_mark} logical vector the length of the ties in the network, diff --git a/man/mark_triangles.Rd b/man/mark_triangles.Rd index bb8e018..2bf8d44 100644 --- a/man/mark_triangles.Rd +++ b/man/mark_triangles.Rd @@ -48,6 +48,14 @@ and fully reciprocated. They are most useful in highlighting parts of the network that are cohesively connected. } +\section{Signed networks}{ + +These marks ask only whether a two-path exists, as a census does, so a tie +counts however it is signed. Where the network is signed, each tie is +therefore read by its magnitude, and every tie keeps its place in the +returned vector. +} + \examples{ ison_monks |> to_uniplex("like") |> mutate_ties(tri = tie_is_triangular()) diff --git a/man/measure_broker_node.Rd b/man/measure_broker_node.Rd index a2f49b2..c01b318 100644 --- a/man/measure_broker_node.Rd +++ b/man/measure_broker_node.Rd @@ -74,6 +74,15 @@ where \eqn{t} is the sum of ties and \eqn{n} the sum of nodes in each node's nei and effective size is calculated as \eqn{n - \frac{2t}{n}}. Node efficiency is the node's effective size divided by its degree. } +\section{Multilevel networks}{ + +A multilevel network reports itself as two-mode, but holds ties within a +mode as well as between them, so it cannot be projected onto one mode. +\code{node_by_effsize()} and \code{node_by_efficiency()} therefore measure a +multilevel network whole, as \code{\link[=net_by_independence]{net_by_independence()}} does. The projection +remains for genuine two-mode networks. +} + \section{Constraint}{ Constraint has a natural floor at 0, for a node whose contacts are wholly diff --git a/man/measure_central_between.Rd b/man/measure_central_between.Rd index 19b4ee5..3baca05 100644 --- a/man/measure_central_between.Rd +++ b/man/measure_central_between.Rd @@ -73,6 +73,15 @@ first transform the salient properties using e.g. \code{\link[manynet:to_undirec All centrality and centralization measures return normalised or scaled measures where available, reported when the measure is printed. } +\section{Signed networks}{ + +These measures read a tie as a distance, and a negative tie is hostility +rather than a channel along which cohesion travels. +Where the network is signed, they therefore consider only the positive +ties, and say so. +Use \code{\link[manynet:to_unsigned]{manynet::to_unsigned()}} first to control this yourself. +} + \section{Betweenness centrality}{ Betweenness centrality is based on the number of shortest paths between diff --git a/man/measure_central_close.Rd b/man/measure_central_close.Rd index f52571c..75606b9 100644 --- a/man/measure_central_close.Rd +++ b/man/measure_central_close.Rd @@ -139,6 +139,15 @@ so that scores can be compared across networks; \code{node_by_randomwalk()} and \code{node_by_distance()} have no such maximum and are instead \emph{scaled} against the largest value observed in this network. } +\section{Signed networks}{ + +These measures read a tie as a distance, and a negative tie is hostility +rather than a channel along which cohesion travels. +Where the network is signed, they therefore consider only the positive +ties, and say so. +Use \code{\link[manynet:to_unsigned]{manynet::to_unsigned()}} first to control this yourself. +} + \section{Closeness centrality}{ Closeness centrality is also known as status centrality, diff --git a/man/measure_central_eigen.Rd b/man/measure_central_eigen.Rd index fa370ba..e3acdf4 100644 --- a/man/measure_central_eigen.Rd +++ b/man/measure_central_eigen.Rd @@ -132,6 +132,23 @@ give scores that are comparable between networks. We use \code{{igraph}} routines behind the scenes here for consistency and because they are often faster. For example, \code{igraph::eigencentrality()} is approximately 25\% faster than \code{sna::evcent()}. } +\section{Signed networks}{ + +These measures do not read a tie as a distance, so where the network is +signed each tie is read by its magnitude rather than the negative ties +being dropped. Use \code{\link[manynet:to_unsigned]{manynet::to_unsigned()}} first to control this +yourself. +} + +\section{Multilevel networks}{ + +A multilevel network reports itself as two-mode, but holds ties within a +mode as well as between them, so it cannot be projected onto one mode. +These measures therefore score a multilevel network whole, as +\code{\link[=net_by_independence]{net_by_independence()}} does. The projection remains for genuine two-mode +networks, where no two nodes of one mode are ever tied. +} + \section{Eigenvector centrality}{ Eigenvector centrality operates as a measure of a node's influence in a network. diff --git a/man/measure_central_tie_between.Rd b/man/measure_central_tie_between.Rd index 86babc7..dd5a5ce 100644 --- a/man/measure_central_tie_between.Rd +++ b/man/measure_central_tie_between.Rd @@ -40,6 +40,16 @@ first transform the salient properties using e.g. \code{\link[manynet:to_undirec All centrality and centralization measures return normalized measures by default, including for two-mode networks. } +\section{Signed networks}{ + +A tie measure holds one value per tie, so \code{tie_by_betweenness()} cannot +drop the negative ties as the distance measures at the node and network +level do. It reads each tie by its magnitude instead, which is what +\code{\link[=node_by_betweenness]{node_by_betweenness()}} in effect does for a network whose ties are signed +but not weighted. Use \code{\link[manynet:to_unsigned]{manynet::to_unsigned()}} first to control this +yourself. +} + \section{Edge betweenness centrality}{ The betweenness centrality of a tie, also known as \emph{edge betweenness}, diff --git a/man/measure_centralisation_between.Rd b/man/measure_centralisation_between.Rd index 54a90cc..0a1cd3e 100644 --- a/man/measure_centralisation_between.Rd +++ b/man/measure_centralisation_between.Rd @@ -61,6 +61,15 @@ against the other nodes of its own mode. Since a two-mode incidence structure gives these no distinct "out" counterpart, \code{mode_by_betweenness()} accepts only \code{"all"} and \code{"in"}. } +\section{Signed networks}{ + +These measures read a tie as a distance, and a negative tie is hostility +rather than a channel along which cohesion travels. +Where the network is signed, they therefore consider only the positive +ties, and say so. +Use \code{\link[manynet:to_unsigned]{manynet::to_unsigned()}} first to control this yourself. +} + \examples{ net_by_betweenness(ison_southern_women) mode_by_betweenness(ison_southern_women, direction = "in") diff --git a/man/measure_centralisation_close.Rd b/man/measure_centralisation_close.Rd index fe0a5da..4919a6e 100644 --- a/man/measure_centralisation_close.Rd +++ b/man/measure_centralisation_close.Rd @@ -80,6 +80,15 @@ For two-mode networks the two modes have different theoretical maxima, so Freeman's general centralization index over the normalized node closeness scores, whereas \code{mode_by_closeness()} reports the per-mode scores directly. } +\section{Signed networks}{ + +These measures read a tie as a distance, and a negative tie is hostility +rather than a channel along which cohesion travels. +Where the network is signed, they therefore consider only the positive +ties, and say so. +Use \code{\link[manynet:to_unsigned]{manynet::to_unsigned()}} first to control this yourself. +} + \section{Decay and integration centralization}{ Unlike reach centrality, decay and integration scores are not bounded above diff --git a/man/measure_fit.Rd b/man/measure_fit.Rd index 2688ceb..3076f57 100644 --- a/man/measure_fit.Rd +++ b/man/measure_fit.Rd @@ -109,6 +109,22 @@ direction, so they are not interchangeable:\tabular{llll}{ Compare partitions using one measure at a time. } +\section{Signed networks}{ + +\code{net_by_modularity()} counts a tie however it is signed, as a census does, +so where the network is signed each tie is read by its magnitude. +} + +\section{Multilevel networks}{ + +\code{net_by_core()} and \code{net_by_factions()} fit the network to an ideal built +by a \verb{manynet::create_*()} function, and those build one layer at a time. +A multilevel network holds two, so there is no single ideal to fit it to +and both stop rather than compare unlike shapes. +Take one layer first, e.g. with \code{\link[manynet:to_mode1]{manynet::to_mode1()}} or +\code{\link[manynet:to_uniplex]{manynet::to_uniplex()}}. +} + \section{Core-periphery fit variants}{ For \code{net_by_core()}, which of the following to use to calculate the fit of diff --git a/man/measure_fragmentation.Rd b/man/measure_fragmentation.Rd index 95ffe22..ccc3d51 100644 --- a/man/measure_fragmentation.Rd +++ b/man/measure_fragmentation.Rd @@ -12,14 +12,19 @@ net_by_cohesion(.data) net_by_adhesion(.data) -net_by_strength(.data) +net_by_strength(.data, limit = 20) -net_by_toughness(.data) +net_by_toughness(.data, limit = 20) } \arguments{ \item{.data}{A network object of class \code{stocnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. Internally any of these will be coerced to an efficient implementation. For more information on possible coercions, see e.g. \code{\link[manynet:as_stocnet]{manynet::as_stocnet()}}.} + +\item{limit}{The largest network \code{net_by_strength()} and +\code{net_by_toughness()} will enumerate: the number of ties for the first and +of nodes for the second. By default 20. Above this each stops rather than +running for hours. Raise it to measure a larger network anyway.} } \value{ A \code{network_measure} numeric score. @@ -45,11 +50,22 @@ from the network needed to increase the number of components. removed from a network to increase its number of components. } } +\section{Cost of the enumerated measures}{ + +\code{net_by_cohesion()} and \code{net_by_adhesion()} are connectivity problems that +\code{{igraph}} solves directly, so they run on a network of any size. +\code{net_by_strength()} and \code{net_by_toughness()} instead take a minimum over +every subset of the tieset or the nodeset, which is \eqn{2^n} subsets. +Their cost therefore quadruples for every two nodes added: a ring of 16 +nodes takes about 8 seconds, one of 20 over two minutes, and one of 30 +more than a day. \code{limit} stops each before it becomes a hang. +} + \examples{ -net_by_cohesion(fict_marvel) -net_by_cohesion(to_giant(fict_marvel)) -net_by_adhesion(fict_marvel) -net_by_adhesion(to_giant(fict_marvel)) +net_by_cohesion(fict_greys) +net_by_cohesion(to_giant(fict_greys)) +net_by_adhesion(fict_greys) +net_by_adhesion(to_giant(fict_greys)) net_by_strength(ison_adolescents) net_by_toughness(ison_adolescents) } diff --git a/man/measure_hierarchy.Rd b/man/measure_hierarchy.Rd index 7dd8b52..4e2d64b 100644 --- a/man/measure_hierarchy.Rd +++ b/man/measure_hierarchy.Rd @@ -39,6 +39,15 @@ or the degree to which network is a single component. \item \code{net_by_upperbound()} measures the Krackhardt (least) upper bound score. } } +\section{Signed networks}{ + +These measures read a tie as a distance, and a negative tie is hostility +rather than a channel along which cohesion travels. +Where the network is signed, they therefore consider only the positive +ties, and say so. +Use \code{\link[manynet:to_unsigned]{manynet::to_unsigned()}} first to control this yourself. +} + \section{Efficiency}{ A perfect hierarchy is a tree: every node but the root has exactly one diff --git a/man/member_community.Rd b/man/member_community.Rd index 4b3b00d..d12cc62 100644 --- a/man/member_community.Rd +++ b/man/member_community.Rd @@ -85,6 +85,16 @@ but does not rest the answer on a single run of a single algorithm. \code{times} applies only when \code{consensus = TRUE}, and is 20 by default. Deterministic algorithms are run once however it is set. } +\section{Signed networks}{ + +Every algorithm but \code{\link[=node_in_spinglass]{node_in_spinglass()}} reads a negative weight as an +error, and spinglass reads a sign as a sign (Traag and Bruggeman 2009). +\code{node_in_community()} therefore considers only spinglass where the network +is signed. Since spinglass needs a connected network and accepts no \code{k}, +a signed network that is unconnected, or a \code{k} that is given, leaves no +applicable algorithm and the function stops. +} + \examples{ node_in_community(ison_adolescents) } diff --git a/man/method_coreness.Rd b/man/method_coreness.Rd index 2bcbce4..0c31e45 100644 --- a/man/method_coreness.Rd +++ b/man/method_coreness.Rd @@ -74,6 +74,15 @@ They differ in what they can use. \code{coreness_rich()} and against a symmetric ideal, so they symmetrise a directed network first and report that they have done so. } +\section{Multilevel networks}{ + +A multilevel network reports itself as two-mode, but holds ties within a +mode as well as between them, so \code{manynet::as_matrix()} returns one square +matrix over every node rather than a rectangular incidence matrix. +These methods therefore read a multilevel network as a one-mode one, which +is the shape its matrix already has. +} + \section{Correlation}{ Borgatti and Everett's continuous model gives each node a coreness diff --git a/man/motif_path.Rd b/man/motif_path.Rd index 179dbb5..81670de 100644 --- a/man/motif_path.Rd +++ b/man/motif_path.Rd @@ -42,6 +42,14 @@ Each block stays the length of the whole nodeset. To census one layer alone, take it first with \code{\link[manynet:to_uniplex]{manynet::to_uniplex()}}. } +\section{Signed networks}{ + +\code{node_x_path()} reads a tie as a distance, and a negative tie is +hostility rather than a channel along which cohesion travels. +Where the network is signed, it therefore considers only the positive +ties. Use \code{\link[manynet:to_unsigned]{manynet::to_unsigned()}} first to control this yourself. +} + \examples{ task_eg <- to_named(to_uniplex(ison_algebra, "tasks")) (tie_cen <- node_x_tie(task_eg)) diff --git a/tests/testthat/helper-netrics.R b/tests/testthat/helper-netrics.R index f276b56..92dc744 100644 --- a/tests/testthat/helper-netrics.R +++ b/tests/testthat/helper-netrics.R @@ -14,6 +14,65 @@ local_verbose <- function(env = parent.frame()){ invisible(old) } +# manynet 2.3.2 changed how two of the `snet_*()` calls signal, so a test that +# asserts on one has to accept either behaviour to pass on 2.3.1 as well. +# +# - `snet_warn()` now raises a warning condition. Before, it printed a cli +# message, and only where the verbosity was not 'quiet'. +# - `snet_unavailable()` now aborts whatever the verbosity. Before, it aborted +# only where the verbosity was not 'quiet'. +# +# Both helpers raise the verbosity for the call alone, which is what 2.3.1 +# needs to speak at all, and which 2.3.2 ignores here. + +# Expects that `object` reports through `snet_warn()`, as either a warning +# condition (manynet >= 2.3.2) or a message (manynet < 2.3.2), whose text +# matches `regexp`. Returns the value of `object`, so that a test can go on to +# assert on the result the warning came with. +expect_snet_warn <- function(object, regexp) { + old <- options(manynet_verbosity = "verbose", snet_verbosity = "verbose") + on.exit(options(old), add = TRUE) + seen <- character() + val <- withCallingHandlers( + force(object), + warning = function(w) { + seen <<- c(seen, conditionMessage(w)) + invokeRestart("muffleWarning") + }, + message = function(m) { + seen <<- c(seen, conditionMessage(m)) + invokeRestart("muffleMessage") + }) + expect_match(paste(seen, collapse = "\n"), regexp, all = FALSE, + label = "the warnings and messages raised") + val +} + +# Returns the value of `object` with any `snet_warn()` output matching +# `regexp` muffled, as either a warning condition (manynet >= 2.3.2) or a +# message (manynet < 2.3.2). A test that reaches a documented warning on its +# way to something else uses this to keep the run's output clean. Only the +# matching text is muffled, so any other warning still reports. +without_snet_warn <- function(object, regexp) { + withCallingHandlers( + force(object), + warning = function(w) { + if(grepl(regexp, conditionMessage(w))) invokeRestart("muffleWarning") + }, + message = function(m) { + if(grepl(regexp, conditionMessage(m))) invokeRestart("muffleMessage") + }) +} + +# Expects that `object` aborts through `snet_abort()` or `snet_unavailable()` +# with a message matching `regexp`. +expect_snet_abort <- function(object, regexp = NULL) { + lab <- deparse(substitute(object)) + old <- options(manynet_verbosity = "verbose", snet_verbosity = "verbose") + on.exit(options(old), add = TRUE) + expect_error(object, regexp, label = lab) +} + expect_values <- function(object, ref, toler = 3) { # 1. Capture object and label # act <- quasi_label(rlang::enquo(object), arg = "object") diff --git a/tests/testthat/test-mark_ties.R b/tests/testthat/test-mark_ties.R index 535c4b6..ee851df 100644 --- a/tests/testthat/test-mark_ties.R +++ b/tests/testthat/test-mark_ties.R @@ -80,3 +80,22 @@ test_that("tie_is_feedback() mark functions work", { c("FALSE", "FALSE", "FALSE", "FALSE", "TRUE", "TRUE", "FALSE", "TRUE", "FALSE", "FALSE")) }) + +test_that("tie_is_max() and tie_is_min() accept a plain numeric vector", { + expect_equal(as.logical(tie_is_max(c(1, 3, 3, 2))), + c(FALSE, TRUE, TRUE, FALSE)) + expect_equal(as.logical(tie_is_min(c(1, 3, 3, 2))), + c(TRUE, FALSE, FALSE, FALSE)) +}) + +test_that("tie_is_max() and tie_is_min() name what they mark", { + # a network reaches `as.numeric()`, which reports a list rather than the + # argument that was wanted + expect_error(tie_is_max(ison_adolescents), "tie measure or a numeric vector") + expect_error(tie_is_min(ison_adolescents), "tie measure or a numeric vector") +}) + +test_that("tie_is_path() names the arguments it needs", { + expect_error(tie_is_path(ison_adolescents), "from") + expect_error(tie_is_path(ison_adolescents, from = 1), "to") +}) diff --git a/tests/testthat/test-measure_cohesion.R b/tests/testthat/test-measure_cohesion.R index 2d70413..e3cb3fe 100644 --- a/tests/testthat/test-measure_cohesion.R +++ b/tests/testthat/test-measure_cohesion.R @@ -45,6 +45,15 @@ test_that("net_strength works", { expect_values(net_by_strength(ison_adolescents), 0.5) }) +test_that("net_by_strength() and net_by_toughness() guard their size", { + # Both take a minimum over 2^n subsets, so above `limit` they stop rather + # than run for hours. See #34. + expect_error(net_by_toughness(fict_actually), "would not finish") + expect_error(net_by_strength(fict_actually), "would not finish") + # a raised `limit` still measures + expect_values(net_by_toughness(create_ring(12), limit = 12), 1) +}) + test_that("net_toughness works", { expect_values(net_by_toughness(ison_adolescents), 0.5) }) diff --git a/tests/testthat/test-measure_heterogeneity.R b/tests/testthat/test-measure_heterogeneity.R index d3aab50..244ea26 100644 --- a/tests/testthat/test-measure_heterogeneity.R +++ b/tests/testthat/test-measure_heterogeneity.R @@ -26,4 +26,9 @@ test_that("richness function works", { test_that("net_spatial works", { expect_values(net_by_spatial(ison_lawfirm, "age"), 0.126) -}) \ No newline at end of file +}) + +test_that("net_by_spatial() names a non-numeric attribute", { + # Moran's I correlates a quantity across ties, so a category cannot be read + expect_error(net_by_spatial(ison_lawfirm, "practice"), "numeric") +}) diff --git a/tests/testthat/test-measure_net.R b/tests/testthat/test-measure_net.R index ccc7516..58bf071 100644 --- a/tests/testthat/test-measure_net.R +++ b/tests/testthat/test-measure_net.R @@ -27,7 +27,13 @@ for(fn in names(net_meas)) { expect_s3_class(net_meas[[fn]](data_objs[[ob]]), "network_measure") else succeed("Only used for diffusion objects") } else { - expect_s3_class(net_meas[[fn]](data_objs[[ob]]), "network_measure") + # An eigenvector score is zero for every node outside the main + # component, so the eigenvector measures warn on the unconnected + # fixtures. The sweep only checks the class, so that one warning is + # muffled here. Every other warning still reports. + expect_s3_class(without_snet_warn(net_meas[[fn]](data_objs[[ob]]), + "eigenvector scores"), + "network_measure") } }) } diff --git a/tests/testthat/test-measure_nodes.R b/tests/testthat/test-measure_nodes.R index adedfd1..87576dd 100644 --- a/tests/testthat/test-measure_nodes.R +++ b/tests/testthat/test-measure_nodes.R @@ -24,8 +24,21 @@ for(fn in names(node_meas)) { succeed("Only used for signed objects") } else if(grepl("distance",fn)){ expect_s3_class(node_meas[[fn]](data_objs[[ob]], 1, 2), "node_measure") + } else if(grepl("eccentricity", fn) && + !manynet::is_connected(.to_positive(data_objs[[ob]]))){ + # An eccentricity is the distance to the furthest node, which is not + # defined where some node cannot be reached at all. The guard says so, + # and aborts. A signed network is measured on its positive ties alone, + # which can disconnect it even where the whole network is connected. + expect_snet_abort(node_meas[[fn]](data_objs[[ob]]), "connected") } else { - expect_s3_class(node_meas[[fn]](data_objs[[ob]]), "node_measure") + # An eigenvector score is zero for every node outside the main + # component, so the eigenvector measures warn on the unconnected + # fixtures. The sweep only checks the class, so that one warning is + # muffled here. Every other warning still reports. + expect_s3_class(without_snet_warn(node_meas[[fn]](data_objs[[ob]]), + "eigenvector scores"), + "node_measure") } }) } diff --git a/tests/testthat/test-member_community.R b/tests/testthat/test-member_community.R index bddd313..f6a473c 100644 --- a/tests/testthat/test-member_community.R +++ b/tests/testthat/test-member_community.R @@ -59,9 +59,11 @@ test_that("every k-capable algorithm returns exactly k communities", { expect_length(res, net_nodes(ison_adolescents)) expect_equal(length(unique(res)), k) } - # node_in_eigen stops splitting early on this network, so it cannot reach k + # node_in_eigen stops splitting early on this network, so it cannot reach k, + # and says so set.seed(1234) - expect_s3_class(node_in_eigen(ison_adolescents, k = 3), "node_member") + expect_s3_class(expect_snet_warn(node_in_eigen(ison_adolescents, k = 3), + "communities"), "node_member") }) test_that("k is recorded in the k attribute of hierarchical memberships", { @@ -95,13 +97,10 @@ test_that("k accepts the selection methods", { }) test_that("an unreachable k warns and returns the nearest", { - local_verbose() # two components cannot be merged into one community unconn <- manynet::create_components(8, membership = c(1,1,1,1,2,2,2,2)) - # snet_warn() signals a cli message, not an R warning condition - expect_match(capture_messages(node_in_betweenness(unconn, k = 1)), - "communities", all = FALSE) - expect_equal(length(unique(suppressMessages(node_in_betweenness(unconn, k = 1)))), 2) + expect_snet_warn(node_in_betweenness(unconn, k = 1), "communities") + expect_equal(length(unique(suppressWarnings(node_in_betweenness(unconn, k = 1)))), 2) }) test_that("node_in_partition preserves its two-group result", { diff --git a/tests/testthat/test-member_equivalence.R b/tests/testthat/test-member_equivalence.R index 457d9a3..629c62f 100644 --- a/tests/testthat/test-member_equivalence.R +++ b/tests/testthat/test-member_equivalence.R @@ -65,7 +65,8 @@ test_that("regularity_rege discriminates on valued networks", { expect_gt(diff(range(r[upper.tri(r)])), 0.1) # but is degenerate on unweighted connected networks: every node comes out # maximally regularly equivalent to every other - expect_true(all(regularity_rege(ison_adolescents) == 1)) + expect_true(all(expect_snet_warn(regularity_rege(ison_adolescents), + "degenerate") == 1)) }) test_that("node_in_regular uses recursive similarity, not a census", { diff --git a/tests/testthat/test-shapes.R b/tests/testthat/test-shapes.R new file mode 100644 index 0000000..5bcacf3 --- /dev/null +++ b/tests/testthat/test-shapes.R @@ -0,0 +1,127 @@ +# Input shapes. These sweep the functions that a signed or a multilevel +# network once aborted, so that a shape which broke them cannot break them +# again silently. See the "Input shapes" section of .github/CONTRIBUTING.md. +# +# `fict_marvel` is signed and multilevel; `fict_actually` is multilevel alone. + +signed_multilevel <- manynet::fict_marvel +multilevel <- manynet::fict_actually + +# Functions that read a tie as a distance, and so drop to the positive ties. +signed_distance <- c("node_by_closeness", "node_by_harmonic", "node_by_reach", + "node_by_decay", "node_by_integration", + "node_by_radiality", "node_by_eccentricity", + "node_by_vitality", "node_by_betweenness", + "node_by_induced", "node_is_fold") + +for(fn in signed_distance){ + test_that(paste(fn, "returns one value per node of a signed network"), { + expect_length(do.call(fn, list(signed_multilevel)), + manynet::net_nodes(signed_multilevel)) + }) +} + +for(fn in c("net_by_closeness", "net_by_betweenness", "net_by_connectedness", + "net_by_reach", "net_by_harmonic", "net_by_decay", + "net_by_integration")){ + test_that(paste(fn, "returns one score for a signed network"), { + expect_length(do.call(fn, list(signed_multilevel)), 1) + }) +} + +for(fn in c("mode_by_closeness", "mode_by_betweenness")){ + test_that(paste(fn, "returns one score per mode of a signed network"), { + expect_length(do.call(fn, list(signed_multilevel)), 2) + }) +} + +# Functions that count a tie however it is signed, and so keep every tie. +for(fn in c("tie_is_transitive", "tie_is_triplet", "tie_is_cyclical", + "tie_by_betweenness")){ + test_that(paste(fn, "returns one value per tie of a signed network"), { + expect_length(do.call(fn, list(signed_multilevel)), + manynet::net_ties(signed_multilevel)) + }) +} + +test_that("node_by_hub() and node_by_authority() do not warn when signed", { + expect_no_warning(node_by_hub(signed_multilevel)) + expect_no_warning(node_by_authority(signed_multilevel)) +}) + +test_that("net_by_modularity() scores a signed network", { + expect_length(net_by_modularity(signed_multilevel), 1) +}) + +test_that("node_in_community() uses spinglass where the network is signed", { + memb <- node_in_community(signed_multilevel) + expect_length(memb, manynet::net_nodes(signed_multilevel)) + # spinglass needs a connected network and accepts no `k`, so neither a `k` + # nor an unconnected signed network leaves any algorithm to try + expect_error(node_in_community(signed_multilevel, k = 3), "signed") +}) + +# A multilevel network reports itself as two-mode but cannot be projected, +# so these measure it whole. +for(fn in c("node_by_eigenvector", "node_by_power", "node_by_efficiency", + "node_by_effsize", "node_is_independent", "node_is_core", + "node_by_core")){ + for(nm in c("signed_multilevel", "multilevel")){ + test_that(paste(fn, "returns one value per node of", nm), { + net <- get(nm) + expect_length(do.call(fn, list(net)), manynet::net_nodes(net)) + }) + } +} + +test_that("a plain two-mode network still takes the projected path", { + sw <- manynet::ison_southern_women + expect_length(node_is_core(sw), manynet::net_nodes(sw)) + expect_length(node_by_eigenvector(sw), manynet::net_nodes(sw)) + expect_length(node_is_independent(sw), manynet::net_nodes(sw)) +}) + +test_that("net_by_core() and net_by_factions() stop on a multilevel network", { + # `manynet::create_*()` builds one layer, so there is no ideal to fit + expect_error(net_by_core(multilevel), "one layer") + expect_error(net_by_factions(multilevel), "one layer") +}) + +test_that(".to_positive() and .to_unsigned() each keep every node", { + pos <- .to_positive(signed_multilevel) + uns <- .to_unsigned(signed_multilevel) + # `net_nodes()` and `net_ties()` carry attributes that differ with the class + # of the network they were given, so the counts are compared as numbers + n <- function(x) as.numeric(x) + expect_equal(n(manynet::net_nodes(pos)), n(manynet::net_nodes(signed_multilevel))) + expect_lt(n(manynet::net_ties(pos)), n(manynet::net_ties(signed_multilevel))) + # every tie counts in a census, so none is dropped + expect_equal(n(manynet::net_ties(uns)), n(manynet::net_ties(signed_multilevel))) + expect_false(manynet::is_signed(uns)) + # an unsigned network passes through both untouched + expect_identical(.to_positive(manynet::ison_adolescents), + manynet::ison_adolescents) + expect_identical(.to_unsigned(manynet::ison_adolescents), + manynet::ison_adolescents) +}) + +test_that(".to_unsigned() returns the class it was given", { + # The measure that calls this passes the result to its `make_*()` + # constructor, so a changed class changes the result's attributes. The + # helper takes `manynet::to_unsigned(keep = "both")` where manynet offers it + # and a fallback otherwise, and both branches have to hold this. + for(net in list(signed_multilevel, + manynet::as_tidygraph(signed_multilevel), + manynet::as_igraph(signed_multilevel))){ + expect_identical(class(.to_unsigned(net)), class(net)) + } + # a sign is held either as the sign of a weight or in a `sign` attribute + bysign <- manynet::add_tie_attribute(manynet::create_ring(6), "sign", + c(1, -1, 1, -1, 1, -1)) + expect_true(manynet::is_signed(bysign)) + out <- .to_unsigned(bysign) + expect_identical(class(out), class(bysign)) + expect_false(manynet::is_signed(out)) + expect_equal(as.numeric(manynet::net_ties(out)), + as.numeric(manynet::net_ties(bysign))) +})