From 7fe186fda65037545fd334301e1e34dbbf1afb7f Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sat, 8 Aug 2026 10:31:19 +0200 Subject: [PATCH 01/68] Added net_by_cyclicality --- NAMESPACE | 1 + NEWS.md | 5 ++++ R/measure_closure.R | 37 ++++++++++++++++++++++++++- man/measure_closure.Rd | 28 ++++++++++++++++++++ tests/testthat/test-measure_closure.R | 15 +++++++++++ 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/NAMESPACE b/NAMESPACE index d0c9e5c..65e0129 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -23,6 +23,7 @@ export(net_by_components) export(net_by_congruency) export(net_by_connectedness) export(net_by_core) +export(net_by_cyclicality) export(net_by_degree) export(net_by_density) export(net_by_diameter) diff --git a/NEWS.md b/NEWS.md index 7d7476d..3859327 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,8 @@ +# netrics 0.5.0 + +## Measures + +- Added `net_by_cyclicality()` for detecting generalised exchange # netrics 0.4.1 ## Package diff --git a/R/measure_closure.R b/R/measure_closure.R index 1d82264..f58b28a 100644 --- a/R/measure_closure.R +++ b/R/measure_closure.R @@ -8,6 +8,7 @@ #' #' - `net_by_reciprocity()` measures reciprocity in a (usually directed) network. #' - `net_by_transitivity()` measures transitivity in a network. +#' - `net_by_cyclicality()` measures cyclicality in a (necessarily directed) network. #' - `net_by_equivalency()` measures equivalence or reinforcement #' in a (usually two-mode) network. #' - `net_by_congruency()` measures congruency across two two-mode networks. @@ -52,7 +53,41 @@ net_by_transitivity <- function(.data) { } #' @rdname measure_closure -#' @section Equivalency: +#' @section Cyclicality: +#' Where transitivity asks how often a two-path \eqn{i \to j \to k} is closed +#' by a tie \eqn{i \to k}, cyclicality asks how often it is closed in the +#' other direction, by \eqn{k \to i}: +#' \deqn{C = \frac{|\{i \to j \to k \to i\}|}{|\{i \to j \to k\}|}} +#' The two capture different social logics. Transitivity is the signature of +#' hierarchy and of "a friend of a friend is a friend", while cyclicality is +#' the signature of generalised exchange, where resources circulate around a +#' loop rather than flowing consistently in one direction. +#' +#' In an undirected network every two-path closed in one direction is also +#' closed in the other, so cyclicality and transitivity coincide. +#' @references +#' ## On cyclicality and generalised exchange +#' Bearman, Peter. 1997. +#' "Generalized Exchange". +#' _American Journal of Sociology_ 102(5): 1383-1415. +#' \doi{10.1086/231087} +#' @examples +#' net_by_cyclicality(ison_networkers) +#' @export +net_by_cyclicality <- function(.data) { + .data <- manynet::expect_nodes(.data) + mat <- manynet::as_matrix(manynet::to_unweighted(.data)) + diag(mat) <- 0 + twopaths <- mat %*% mat + diag(twopaths) <- 0 # i -> j -> i is not a two-path + denom <- sum(twopaths) + # closed cyclically where a tie runs back from k to i + out <- if(denom == 0) NaN else sum(twopaths * t(mat))/denom + make_network_measure(out, .data, call = deparse(sys.call())) +} + +#' @rdname measure_closure +#' @section Equivalency: #' The `net_by_equivalency()` function calculates the Robins and Alexander (2004) #' clustering coefficient for two-mode networks. #' Note that for weighted two-mode networks, the result is divided by the average tie weight. diff --git a/man/measure_closure.Rd b/man/measure_closure.Rd index ad5e94b..de0b15d 100644 --- a/man/measure_closure.Rd +++ b/man/measure_closure.Rd @@ -4,6 +4,7 @@ \alias{measure_closure} \alias{net_by_reciprocity} \alias{net_by_transitivity} +\alias{net_by_cyclicality} \alias{net_by_equivalency} \alias{net_by_congruency} \title{Measuring network closure} @@ -12,6 +13,8 @@ net_by_reciprocity(.data, method = "default") net_by_transitivity(.data) +net_by_cyclicality(.data) + net_by_equivalency(.data) net_by_congruency(.data, object2) @@ -35,6 +38,7 @@ in one-, two-, and three-mode networks: \itemize{ \item \code{net_by_reciprocity()} measures reciprocity in a (usually directed) network. \item \code{net_by_transitivity()} measures transitivity in a network. +\item \code{net_by_cyclicality()} measures cyclicality in a (necessarily directed) network. \item \code{net_by_equivalency()} measures equivalence or reinforcement in a (usually two-mode) network. \item \code{net_by_congruency()} measures congruency across two two-mode networks. @@ -51,6 +55,21 @@ For three-mode networks, \code{net_congruency} calculates the proportion of thre spanning two two-mode networks that are closed by a fourth tie to establish a "congruent four-cycle" structure. } +\section{Cyclicality}{ + +Where transitivity asks how often a two-path \eqn{i \to j \to k} is closed +by a tie \eqn{i \to k}, cyclicality asks how often it is closed in the +other direction, by \eqn{k \to i}: +\deqn{C = \frac{|\{i \to j \to k \to i\}|}{|\{i \to j \to k\}|}} +The two capture different social logics. Transitivity is the signature of +hierarchy and of "a friend of a friend is a friend", while cyclicality is +the signature of generalised exchange, where resources circulate around a +loop rather than flowing consistently in one direction. + +In an undirected network every two-path closed in one direction is also +closed in the other, so cyclicality and transitivity coincide. +} + \section{Equivalency}{ The \code{net_by_equivalency()} function calculates the Robins and Alexander (2004) @@ -61,9 +80,18 @@ Note that for weighted two-mode networks, the result is divided by the average t \examples{ net_by_reciprocity(ison_southern_women) net_by_transitivity(ison_adolescents) +net_by_cyclicality(ison_networkers) net_by_equivalency(ison_southern_women) } \references{ +\subsection{On cyclicality and generalised exchange}{ + +Bearman, Peter. 1997. +"Generalized Exchange". +\emph{American Journal of Sociology} 102(5): 1383-1415. +\doi{10.1086/231087} +} + \subsection{On equivalency or four-cycles}{ Robins, Garry L, and Malcolm Alexander. 2004. diff --git a/tests/testthat/test-measure_closure.R b/tests/testthat/test-measure_closure.R index 9efe669..696f1cb 100644 --- a/tests/testthat/test-measure_closure.R +++ b/tests/testthat/test-measure_closure.R @@ -23,3 +23,18 @@ test_that("three-mode clustering calculated correctly",{ expect_equal(as.numeric(net_by_congruency(mat1, mat2)), 0.3684, tolerance = 0.001) }) + +test_that("network cyclicality works", { + # a pure 3-cycle is fully cyclical but not transitive + cyc <- matrix(c(0,1,0, 0,0,1, 1,0,0), 3, 3, byrow = TRUE) + expect_equal(as.numeric(net_by_cyclicality(cyc)), 1) + # a transitive triple is the reverse + tri <- matrix(c(0,1,1, 0,0,1, 0,0,0), 3, 3, byrow = TRUE) + expect_equal(as.numeric(net_by_cyclicality(tri)), 0) + # undirected networks close two-paths in both directions equally + expect_equal(as.numeric(net_by_cyclicality(ison_adolescents)), + as.numeric(net_by_transitivity(ison_adolescents))) + expect_equal(as.numeric(net_by_cyclicality(ison_networkers)), + 0.5912, tolerance = 0.001) + expect_output(print(net_by_cyclicality(ison_networkers))) +}) From df6f489f62de59e0ea531b558273cd2e3a019e07 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sat, 8 Aug 2026 10:31:54 +0200 Subject: [PATCH 02/68] Updated GitHub Actions workflows to latest major action versions --- .github/workflows/prchecks.yml | 8 ++++---- .github/workflows/pushrelease.yml | 12 ++++++------ DESCRIPTION | 2 +- NEWS.md | 4 ++++ 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/prchecks.yml b/.github/workflows/prchecks.yml index f1c97bc..ac4ffdf 100644 --- a/.github/workflows/prchecks.yml +++ b/.github/workflows/prchecks.yml @@ -24,7 +24,7 @@ jobs: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: r-lib/actions/setup-r@v2 with: @@ -54,7 +54,7 @@ jobs: shell: Rscript {0} - name: Save binary artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.config.asset_name }} path: build/ @@ -78,7 +78,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: r-lib/actions/setup-r@v2 @@ -97,7 +97,7 @@ jobs: runs-on: macOS-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 diff --git a/.github/workflows/pushrelease.yml b/.github/workflows/pushrelease.yml index bbb925b..2af20a9 100644 --- a/.github/workflows/pushrelease.yml +++ b/.github/workflows/pushrelease.yml @@ -24,7 +24,7 @@ jobs: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: r-lib/actions/setup-r@v2 with: @@ -55,7 +55,7 @@ jobs: shell: Rscript {0} - name: Save binary artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.config.asset_name }} path: build/ @@ -75,7 +75,7 @@ jobs: contents: write steps: - name: Checkout one - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: '0' - name: Bump version and push tag @@ -87,7 +87,7 @@ jobs: DEFAULT_BUMP: patch RELEASE_BRANCHES: main - name: Checkout two - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Extract version run: | @@ -95,7 +95,7 @@ jobs: echo "PACKAGE_NAME=$(grep '^Package' DESCRIPTION | sed 's/.*: *//')" >> $GITHUB_ENV - name: Download binaries - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 - name: Rename binaries release shell: bash @@ -132,7 +132,7 @@ jobs: env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: r-lib/actions/setup-r@v2 diff --git a/DESCRIPTION b/DESCRIPTION index 564de64..ebf3f00 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: netrics Title: Many Ways to Measure and Classify Membership for Networks, Nodes, and Ties -Version: 0.4.1 +Version: 0.5.0 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, diff --git a/NEWS.md b/NEWS.md index 3859327..1deb815 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,9 @@ # netrics 0.5.0 +## Package + +- Updated GitHub Actions workflows to latest major action versions +- Fixed release workflow referring to `actions/actions/checkout`, a doubled path segment that would have failed every step using it ## Measures - Added `net_by_cyclicality()` for detecting generalised exchange From 57f8249b08667adb50c5eeb6e69bd5155258b7cf Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sat, 8 Aug 2026 16:08:21 +0200 Subject: [PATCH 03/68] Removed the CRAN version check from `.onAttach()` making `library(netrics)` faster to attach --- NEWS.md | 2 ++ R/zzz.R | 17 ++--------------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/NEWS.md b/NEWS.md index 1deb815..640af00 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,8 @@ ## Package +- Removed the CRAN version check from `.onAttach()` making `library(netrics)` faster to attach + - It now runs once, for the whole stack, in `{migraph}`, where it is cached and checks GitHub as well as CRAN - Updated GitHub Actions workflows to latest major action versions - Fixed release workflow referring to `actions/actions/checkout`, a doubled path segment that would have failed every step using it ## Measures diff --git a/R/zzz.R b/R/zzz.R index e5a2416..33d3d50 100644 --- a/R/zzz.R +++ b/R/zzz.R @@ -13,9 +13,7 @@ local_version <- utils::packageVersion("netrics") manynet::snet_info("You are using {.pkg netrics} version {.version {local_version}}.") - old.list <- as.data.frame(utils::old.packages()) - behind_cran <- "netrics" %in% old.list$Package - + greet_startup_cli <- function() { tips <- c( "i" = "Contribute to {.pkg netrics} at {.url https://github.com/stocnet/netrics/}.", @@ -33,18 +31,7 @@ manynet::snet_info(sample(tips, 1)) } - if (interactive()) { - if (behind_cran) { - msg <- "A new version of netrics is available with bug fixes and new features." - packageStartupMessage(msg, "\nWould you like to install it?") - if (utils::menu(c("Yes", "No")) == 1) { - utils::update.packages("netrics") - } - } else { - greet_startup_cli() - # packageStartupMessage(paste(strwrap(tip), collapse = "\n")) - } - } + greet_startup_cli() } From e01ce8588117c201528b773dd1a28c870677dde9 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sat, 8 Aug 2026 16:12:00 +0200 Subject: [PATCH 04/68] Added `node_in_labels()` for label propagation community detection --- NAMESPACE | 1 + NEWS.md | 4 +++ R/member_community.R | 46 ++++++++++++++++++++++++-- man/member_community_non.Rd | 38 ++++++++++++++++++++- tests/testthat/test-member_community.R | 14 +++++++- 5 files changed, 98 insertions(+), 5 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 65e0129..e6d1df3 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -129,6 +129,7 @@ export(node_in_equivalence) export(node_in_fluid) export(node_in_greedy) export(node_in_infomap) +export(node_in_labels) export(node_in_leiden) export(node_in_louvain) export(node_in_optimal) diff --git a/NEWS.md b/NEWS.md index 640af00..1b3407f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -9,6 +9,10 @@ ## Measures - Added `net_by_cyclicality()` for detecting generalised exchange + +## Memberships + +- Added `node_in_labels()` for label propagation community detection # netrics 0.4.1 ## Package diff --git a/R/member_community.R b/R/member_community.R index 1e13ff5..119e442 100644 --- a/R/member_community.R +++ b/R/member_community.R @@ -107,9 +107,11 @@ node_in_community <- function(.data){ #' based on analogy to model from fluid dynamics. #' - `node_in_louvain()` is an agglomerative multilevel algorithm that seeks to maximise #' modularity over all possible partitions. -#' - `node_in_leiden()` is an agglomerative multilevel algorithm that seeks to maximise +#' - `node_in_leiden()` is an agglomerative multilevel algorithm that seeks to maximise #' the Constant Potts Model over all possible partitions. -#' +#' - `node_in_labels()` is a fast, propagation-based algorithm in which nodes +#' iteratively adopt whichever community label is most common among their neighbours. +#' #' The different algorithms offer various advantages in terms of computation time, #' availability on different types of networks, ability to maximise modularity, #' and their logic or domain of inspiration. @@ -395,12 +397,50 @@ node_in_leiden <- function(.data, resolution = 1){ n <- manynet::net_nodes(.data) resolution <- sum(manynet::tie_weights(.data))/(n*(n - 1)/2) } - out <- igraph::cluster_leiden(manynet::as_igraph(.data), + out <- igraph::cluster_leiden(manynet::as_igraph(.data), resolution = resolution )$membership make_node_member(out, .data) } +#' @rdname member_community_non +#' @section Label propagation: +#' Every node is initially given a unique label. +#' Nodes are then visited in random order, each adopting whichever label is +#' most frequent among its neighbours, until no node has a label that a +#' majority of its neighbours does not share. +#' Densely connected groups quickly converge on a common label, +#' which is what makes the communities. +#' +#' This is the fastest of the algorithms here, running in near-linear time, +#' which makes it useful on large networks where the others are infeasible. +#' The trade-off is that it is stochastic: because both the visiting order and +#' ties between equally frequent labels are broken at random, repeated runs on +#' the same network can return different partitions, +#' and on sparse networks it may return a single community. +#' Set a seed for reproducibility, or use `node_in_community()` to select +#' among algorithms by modularity. +#' @references +#' ## On label propagation community detection +#' Raghavan, Usha Nandini, Reka Albert, and Soundar Kumara. 2007. +#' "Near linear time algorithm to detect community structures in large-scale networks", +#' _Physical Review E_, 76(3):036106. +#' \doi{10.1103/PhysRevE.76.036106} +#' @examples +#' node_in_labels(ison_adolescents) +#' @export +node_in_labels <- function(.data){ + .data <- manynet::expect_nodes(.data) + if(manynet::is_directed(.data)){ + manynet::snet_info("This algorithm only works for undirected networks.", + "Converting to undirected") + .data <- manynet::to_undirected(.data) + } + out <- igraph::cluster_label_prop(manynet::as_igraph(.data) + )$membership + make_node_member(out, .data) +} + # Hierarchical community clustering #### #' Memberships in hierarchical communities diff --git a/man/member_community_non.Rd b/man/member_community_non.Rd index eadc5e0..40d0b78 100644 --- a/man/member_community_non.Rd +++ b/man/member_community_non.Rd @@ -9,6 +9,7 @@ \alias{node_in_fluid} \alias{node_in_louvain} \alias{node_in_leiden} +\alias{node_in_labels} \title{Memberships in non-hierarchical communities} \usage{ node_in_optimal(.data) @@ -24,6 +25,8 @@ node_in_fluid(.data) node_in_louvain(.data, resolution = 1) node_in_leiden(.data, resolution = 1) + +node_in_labels(.data) } \arguments{ \item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. @@ -64,6 +67,8 @@ based on analogy to model from fluid dynamics. modularity over all possible partitions. \item \code{node_in_leiden()} is an agglomerative multilevel algorithm that seeks to maximise the Constant Potts Model over all possible partitions. +\item \code{node_in_labels()} is a fast, propagation-based algorithm in which nodes +iteratively adopt whichever community label is most common among their neighbours. } The different algorithms offer various advantages in terms of computation time, @@ -149,6 +154,25 @@ Compared to the Louvain method, the Leiden algorithm additionally tries to avoid unconnected communities. } +\section{Label propagation}{ + +Every node is initially given a unique label. +Nodes are then visited in random order, each adopting whichever label is +most frequent among its neighbours, until no node has a label that a +majority of its neighbours does not share. +Densely connected groups quickly converge on a common label, +which is what makes the communities. + +This is the fastest of the algorithms here, running in near-linear time, +which makes it useful on large networks where the others are infeasible. +The trade-off is that it is stochastic: because both the visiting order and +ties between equally frequent labels are broken at random, repeated runs on +the same network can return different partitions, +and on sparse networks it may return a single community. +Set a seed for reproducibility, or use \code{node_in_community()} to select +among algorithms by modularity. +} + \examples{ node_in_optimal(ison_adolescents) node_in_partition(ison_adolescents) @@ -158,6 +182,7 @@ node_in_spinglass(ison_adolescents) node_in_fluid(ison_adolescents) node_in_louvain(ison_adolescents) node_in_leiden(ison_adolescents) +node_in_labels(ison_adolescents) } \references{ \subsection{On optimal community detection}{ @@ -224,6 +249,14 @@ Traag, Vincent A., Ludo Waltman, and Nees Jan van Eck. 2019. \emph{Scientific Reports}, 9(1):5233. \doi{10.1038/s41598-019-41695-z} } + +\subsection{On label propagation community detection}{ + +Raghavan, Usha Nandini, Reka Albert, and Soundar Kumara. 2007. +"Near linear time algorithm to detect community structures in large-scale networks", +\emph{Physical Review E}, 76(3):036106. +\doi{10.1103/PhysRevE.76.036106} +} } \seealso{ Other community: @@ -238,7 +271,8 @@ Other memberships: \code{\link{member_components}}, \code{\link{member_core}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}} +\code{\link{member_equivalence}}, +\code{\link{method_equivalence}} Other nodal: \code{\link{mark_core}}, @@ -266,6 +300,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/tests/testthat/test-member_community.R b/tests/testthat/test-member_community.R index 1f27e16..9c6a038 100644 --- a/tests/testthat/test-member_community.R +++ b/tests/testthat/test-member_community.R @@ -31,4 +31,16 @@ test_that("node_in_community uses node_in_optimal on small networks", { expect_message(node_in_community(manynet::create_ring(200)), "xcluding") options(manynet_verbosity = "quiet") options(snet_verbosity = "quiet") -}) \ No newline at end of file +}) +test_that("label propagation membership works", { + # stochastic, so assert on structure rather than exact labels + set.seed(1234) + res <- node_in_labels(ison_adolescents) + expect_s3_class(res, "node_member") + expect_length(res, manynet::net_nodes(ison_adolescents)) + expect_gte(length(unique(res)), 1) + expect_output(print(node_in_labels(ison_adolescents))) + # directed networks are converted rather than refused + expect_length(node_in_labels(ison_networkers), + manynet::net_nodes(ison_networkers)) +}) From 244fd4c65a614c3a6201046107b3770de982907b Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sat, 8 Aug 2026 16:12:26 +0200 Subject: [PATCH 05/68] Added `param_cutoff` roxygen template, correctly documenting geodesic cutoff for six functions --- NEWS.md | 2 ++ man-roxygen/param_cutoff.R | 5 +++++ 2 files changed, 7 insertions(+) create mode 100644 man-roxygen/param_cutoff.R diff --git a/NEWS.md b/NEWS.md index 1b3407f..eee3576 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,8 @@ - It now runs once, for the whole stack, in `{migraph}`, where it is cached and checks GitHub as well as CRAN - Updated GitHub Actions workflows to latest major action versions - Fixed release workflow referring to `actions/actions/checkout`, a doubled path segment that would have failed every step using it +- Added `param_cutoff` roxygen template, correctly documenting geodesic cutoff for six functions + ## Measures - Added `net_by_cyclicality()` for detecting generalised exchange diff --git a/man-roxygen/param_cutoff.R b/man-roxygen/param_cutoff.R new file mode 100644 index 0000000..df81bba --- /dev/null +++ b/man-roxygen/param_cutoff.R @@ -0,0 +1,5 @@ +#' @param cutoff Integer scalar, the maximum path length considered. +#' Paths longer than this are ignored, which restricts the measure to a +#' node's local neighbourhood. +#' Where a measure is defined over all paths by default, +#' a negative value or `NULL` imposes no limit. From ba48febc3d6d5d222e7709552c56da04f20c1035 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 07:07:34 +0200 Subject: [PATCH 06/68] Added `node_x_clique()` returning which maximal cliques each node belongs to --- NEWS.md | 7 +- R/motif_cliques.R | 80 +++++++++++++++++++ man/motif_clique.Rd | 118 ++++++++++++++++++++++++++++ tests/testthat/test-motif_cliques.R | 43 ++++++++++ 4 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 R/motif_cliques.R create mode 100644 man/motif_clique.Rd create mode 100644 tests/testthat/test-motif_cliques.R diff --git a/NEWS.md b/NEWS.md index eee3576..604e732 100644 --- a/NEWS.md +++ b/NEWS.md @@ -15,11 +15,16 @@ ## Memberships - Added `node_in_labels()` for label propagation community detection + +## Motifs + +- Added `node_x_clique()`, returning which maximal cliques each node belongs to, + and branching on two-mode networks to find bicliques (closes #8, thanks @noortjemay) + # netrics 0.4.1 ## Package -- Updated actions/checkout@v2 to actions/checkout@v4 - Fixed the website deploy job installing `Config/Needs/check` packages instead of `Config/Needs/website`, which meant `{learnr}` was never actually installed before the pkgdown deploy step ## Tutorials diff --git a/R/motif_cliques.R b/R/motif_cliques.R new file mode 100644 index 0000000..3b6a634 --- /dev/null +++ b/R/motif_cliques.R @@ -0,0 +1,80 @@ +# Clique participation #### + +#' Motifs of clique participation +#' @name motif_clique +#' @description +#' `node_x_clique()` returns which maximal cliques each node belongs to. +#' +#' A clique is a set of nodes every one of which is tied to every other, +#' and it is maximal if no further node can be added without breaking that. +#' Cliques are the strictest notion of a cohesive subgroup, +#' and unlike the communities returned by `node_in_*()` functions they +#' _overlap_: a node may belong to many cliques at once, or to none. +#' That is why this returns an incidence table rather than a membership +#' vector. +#' @template param_data +#' @param min_clique_size Integer, the minimum size of clique to return. +#' By default 3, since dyads and isolates are trivially cliques. +#' For a two-mode network, a vector of two values giving the minimum number +#' of nodes from each mode, by default `c(3, 3)`. +#' @family motifs +#' @template node_motif +#' @section Bicliques: +#' In a two-mode network no two nodes of the same mode are ever tied +#' directly, so no set of them is a clique in the ordinary sense. +#' The two-mode analogue is a _biclique_: a set of nodes from each mode such +#' that every node of the one is tied to every node of the other. +#' `node_x_clique()` detects these by connecting nodes that share a partner +#' before searching, so that a biclique becomes an ordinary clique, +#' and then keeping only those cliques with at least `min_clique_size` nodes +#' from each mode. +#' @section Signed networks: +#' Since a clique is a maximally cohesive subgroup, negative ties cannot +#' contribute to one. Where the network is signed, only its positive ties are +#' considered. Use [manynet::to_unsigned()] first to control this yourself. +#' @references +#' ## On cliques +#' Luce, R. Duncan, and Albert D. Perry. 1949. +#' "A method of matrix analysis of group structure". +#' _Psychometrika_ 14(2): 95-116. +#' \doi{10.1007/BF02289146} +#' @examples +#' node_x_clique(ison_adolescents) +#' node_x_clique(ison_southern_women, min = c(3, 3)) +#' @export +node_x_clique <- function(.data, min = 3){ + .data <- manynet::expect_nodes(.data) + twomode <- manynet::is_twomode(.data) + if(twomode && length(min) == 1) min <- c(min, min) + # a clique is a cohesive subgroup, so where ties are signed only the + # positive ones can contribute to one + if(manynet::is_signed(.data)) + .data <- manynet::to_unsigned(.data, keep = "positive") + mat <- manynet::as_matrix(manynet::to_undirected( + manynet::to_unweighted(manynet::to_multilevel(.data)))) + if(twomode){ + # two nodes of a mode that share a partner are made adjacent, so that a + # biclique becomes an ordinary clique of the combined node set + mat <- ((mat %*% mat) + mat) > 0 + diag(mat) <- 0 + smallest <- sum(min) + } else smallest <- min + graph <- igraph::graph_from_adjacency_matrix(mat*1, mode = "undirected", + diag = FALSE) + cliques <- igraph::max_cliques(graph, min = smallest) + if(twomode){ + modes <- manynet::node_is_mode(.data) + keep <- vapply(cliques, function(cl) + sum(!modes[cl]) >= min[1] && sum(modes[cl]) >= min[2], + FUN.VALUE = logical(1)) + cliques <- cliques[keep] + } + out <- matrix(0L, nrow = manynet::net_nodes(.data), + ncol = length(cliques)) + for(j in seq_along(cliques)) out[as.integer(cliques[[j]]), j] <- 1L + colnames(out) <- if(length(cliques) > 0) + paste0("C", seq_along(cliques)) else character(0) + if(length(cliques) == 0) + manynet::snet_info("No cliques of at least this size were found.") + make_node_motif(out, .data) +} diff --git a/man/motif_clique.Rd b/man/motif_clique.Rd new file mode 100644 index 0000000..5af6814 --- /dev/null +++ b/man/motif_clique.Rd @@ -0,0 +1,118 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/motif_cliques.R +\name{motif_clique} +\alias{motif_clique} +\alias{node_x_clique} +\title{Motifs of clique participation} +\usage{ +node_x_clique(.data, min = 3) +} +\arguments{ +\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. +For more information on the standard coercion possible, +see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} + +\item{min_clique_size}{Integer, the minimum size of clique to return. +By default 3, since dyads and isolates are trivially cliques. +For a two-mode network, a vector of two values giving the minimum number +of nodes from each mode, by default \code{c(3, 3)}.} +} +\value{ +A \code{node_motif} matrix with one row for each node in the network and +a column for each motif type, +giving the count of each motif in which each node participates. +It is printed as a tibble, however, to avoid greedy printing. +If the network is labelled, +then the node names will be in a column named \code{names}. +} +\description{ +\code{node_x_clique()} returns which maximal cliques each node belongs to. + +A clique is a set of nodes every one of which is tied to every other, +and it is maximal if no further node can be added without breaking that. +Cliques are the strictest notion of a cohesive subgroup, +and unlike the communities returned by \verb{node_in_*()} functions they +\emph{overlap}: a node may belong to many cliques at once, or to none. +That is why this returns an incidence table rather than a membership +vector. +} +\section{Bicliques}{ + +In a two-mode network no two nodes of the same mode are ever tied +directly, so no set of them is a clique in the ordinary sense. +The two-mode analogue is a \emph{biclique}: a set of nodes from each mode such +that every node of the one is tied to every node of the other. +\code{node_x_clique()} detects these by connecting nodes that share a partner +before searching, so that a biclique becomes an ordinary clique, +and then keeping only those cliques with at least \code{min_clique_size} nodes +from each mode. +} + +\section{Signed networks}{ + +Since a clique is a maximally cohesive subgroup, negative ties cannot +contribute to one. Where the network is signed, only its positive ties are +considered. Use \code{\link[manynet:to_unsigned]{manynet::to_unsigned()}} first to control this yourself. +} + +\examples{ +node_x_clique(ison_adolescents) +node_x_clique(ison_southern_women, min = c(3, 3)) +} +\references{ +\subsection{On cliques}{ + +Luce, R. Duncan, and Albert D. Perry. 1949. +"A method of matrix analysis of group structure". +\emph{Psychometrika} 14(2): 95-116. +\doi{10.1007/BF02289146} +} +} +\seealso{ +Other motifs: +\code{\link{motif_brokerage_net}}, +\code{\link{motif_brokerage_node}}, +\code{\link{motif_composition}}, +\code{\link{motif_exposure}}, +\code{\link{motif_hazard}}, +\code{\link{motif_hierarchy}}, +\code{\link{motif_homophily}}, +\code{\link{motif_net}}, +\code{\link{motif_node}}, +\code{\link{motif_path}}, +\code{\link{motif_periods}} + +Other nodal: +\code{\link{mark_core}}, +\code{\link{mark_degree}}, +\code{\link{mark_diff}}, +\code{\link{mark_nodes}}, +\code{\link{mark_select_node}}, +\code{\link{measure_assort_node}}, +\code{\link{measure_broker_node}}, +\code{\link{measure_brokerage}}, +\code{\link{measure_central_between}}, +\code{\link{measure_central_close}}, +\code{\link{measure_central_degree}}, +\code{\link{measure_central_eigen}}, +\code{\link{measure_closure_node}}, +\code{\link{measure_core}}, +\code{\link{measure_diffusion_node}}, +\code{\link{measure_diverse_node}}, +\code{\link{member_brokerage}}, +\code{\link{member_cliques}}, +\code{\link{member_community}}, +\code{\link{member_community_hier}}, +\code{\link{member_community_non}}, +\code{\link{member_components}}, +\code{\link{member_core}}, +\code{\link{member_diffusion}}, +\code{\link{member_equivalence}}, +\code{\link{motif_brokerage_node}}, +\code{\link{motif_composition}}, +\code{\link{motif_exposure}}, +\code{\link{motif_node}}, +\code{\link{motif_path}} +} +\concept{motifs} +\concept{nodal} diff --git a/tests/testthat/test-motif_cliques.R b/tests/testthat/test-motif_cliques.R new file mode 100644 index 0000000..b2a099f --- /dev/null +++ b/tests/testthat/test-motif_cliques.R @@ -0,0 +1,43 @@ +test_that("node_x_clique finds the maximal cliques", { + res <- node_x_clique(ison_adolescents) + expect_s3_class(res, "node_motif") + expect_equal(nrow(res), c(manynet::net_nodes(ison_adolescents))) + # the same cliques igraph finds + expect_equal(ncol(res), + length(igraph::max_cliques(manynet::as_igraph(ison_adolescents), + min = 3))) + # every returned clique respects the minimum size + expect_true(all(colSums(res) >= 3)) + expect_true(all(colSums(node_x_clique(ison_adolescents, min = 4)) >= 4)) + # and every returned clique really is complete + mat <- manynet::as_matrix(ison_adolescents) + for (j in seq_len(ncol(res))) { + members <- which(res[, j] == 1) + sub <- mat[members, members] + diag(sub) <- 1 + expect_true(all(sub == 1)) + } +}) + +test_that("node_x_clique finds bicliques in two-mode networks", { + res <- node_x_clique(ison_southern_women, min = c(3, 3)) + expect_s3_class(res, "node_motif") + expect_equal(nrow(res), c(manynet::net_nodes(ison_southern_women))) + modes <- manynet::node_is_mode(ison_southern_women) + # each biclique draws at least the minimum from both modes + expect_true(all(apply(res, 2, function(x) + sum(x == 1 & !modes) >= 3 && sum(x == 1 & modes) >= 3))) + # and is complete between the two modes + mat <- manynet::as_matrix(ison_southern_women) + for (j in seq_len(ncol(res))) { + members <- which(res[, j] == 1) + rows <- members[members <= nrow(mat)] + cols <- members[members > nrow(mat)] - nrow(mat) + expect_true(all(mat[rows, cols] == 1)) + } +}) + +test_that("node_x_clique handles networks with no cliques", { + res <- node_x_clique(create_empty(6)) + expect_equal(dim(res), c(6L, 0L)) +}) From be50e87c011343c1bd2afd04a1799d9ff92a4acc Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 08:19:20 +0200 Subject: [PATCH 07/68] Added node_x_ties(), node_x_alters(), node_x_similarity(), and net_x_homophily() --- NAMESPACE | 5 + NEWS.md | 15 + R/motif_composition.R | 396 ++++++++++++++++++++++++ man/mark_core.Rd | 2 + man/mark_degree.Rd | 2 + man/mark_diff.Rd | 2 + man/mark_nodes.Rd | 2 + man/mark_select_node.Rd | 2 + man/measure_assort_net.Rd | 4 +- man/measure_assort_node.Rd | 6 +- man/measure_broker_node.Rd | 2 + man/measure_brokerage.Rd | 2 + man/measure_central_between.Rd | 2 + man/measure_central_degree.Rd | 2 + man/measure_central_eigen.Rd | 2 + man/measure_closure_node.Rd | 2 + man/measure_core.Rd | 2 + man/measure_diffusion_node.Rd | 2 + man/measure_diverse_net.Rd | 4 +- man/measure_diverse_node.Rd | 6 +- man/member_brokerage.Rd | 5 +- man/member_cliques.Rd | 5 +- man/member_community.Rd | 5 +- man/member_community_hier.Rd | 5 +- man/member_components.Rd | 5 +- man/member_core.Rd | 5 +- man/member_diffusion.Rd | 5 +- man/motif_brokerage_net.Rd | 3 + man/motif_brokerage_node.Rd | 5 + man/motif_composition.Rd | 239 ++++++++++++++ man/motif_exposure.Rd | 5 + man/motif_hazard.Rd | 3 + man/motif_hierarchy.Rd | 3 + man/motif_homophily.Rd | 87 ++++++ man/motif_net.Rd | 3 + man/motif_node.Rd | 5 + man/motif_path.Rd | 5 + man/motif_periods.Rd | 3 + tests/testthat/test-motif_composition.R | 120 +++++++ tests/testthat/test-motif_net.R | 4 +- tests/testthat/test-motif_nodes.R | 2 +- 41 files changed, 971 insertions(+), 13 deletions(-) create mode 100644 R/motif_composition.R create mode 100644 man/motif_composition.Rd create mode 100644 man/motif_homophily.Rd create mode 100644 tests/testthat/test-motif_composition.R diff --git a/NAMESPACE b/NAMESPACE index e6d1df3..cbc837a 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -65,6 +65,7 @@ export(net_x_correlation) export(net_x_dyad) export(net_x_hazard) export(net_x_hierarchy) +export(net_x_homophily) export(net_x_mixed) export(net_x_stability) export(net_x_tetrad) @@ -158,12 +159,16 @@ export(node_is_pendant) export(node_is_random) export(node_is_recovered) export(node_is_universal) +export(node_x_alters) export(node_x_brokerage) +export(node_x_clique) export(node_x_dyad) export(node_x_exposure) export(node_x_path) +export(node_x_similarity) export(node_x_tetrad) export(node_x_tie) +export(node_x_ties) export(node_x_triad) export(tie_by_betweenness) export(tie_by_closeness) diff --git a/NEWS.md b/NEWS.md index 604e732..ff3e588 100644 --- a/NEWS.md +++ b/NEWS.md @@ -20,6 +20,21 @@ - Added `node_x_clique()`, returning which maximal cliques each node belongs to, and branching on two-mode networks to find bicliques (closes #8, thanks @noortjemay) + - Note that `node_x_clique()` considers only positive ties, + since a clique is a cohesive subgroup +- Added `node_x_ties()`, describing the distribution of each node's tie values, +or its spread across layers in a multiplex network +- Added `node_x_alters()` and `node_x_similarity()`, describing the composition + of each node's alters and their similarity to it, each branching on whether + the attribute given is categorical or continuous + - For two-mode networks, `node_x_similarity()` compares each node with those + at distance two, that is, those it shares a node of the other mode with, + following the tertius effect of `{migraph}` and `{goldfish}` + (Haunss and Hollway 2023) +- Added `net_x_homophily()`, returning the table behind the EI index together + with an expected-EI baseline and Yule's Q + - Note that on weighted networks this counts ties where + `net_by_heterophily()` sums weights, so the two agree only when unweighted # netrics 0.4.1 diff --git a/R/motif_composition.R b/R/motif_composition.R new file mode 100644 index 0000000..9114d03 --- /dev/null +++ b/R/motif_composition.R @@ -0,0 +1,396 @@ +# Ego-network composition #### + +#' Motifs of ego-network composition +#' @name motif_composition +#' @description +#' These functions describe the composition of each node's ego-network, +#' that is, what the ties and alters surrounding each node look like: +#' +#' - `node_x_ties()` describes the distribution of each node's tie values, +#' or, in a multiplex network, how its ties are spread across layers. +#' - `node_x_alters()` describes the composition of each node's alters on +#' some attribute. +#' - `node_x_similarity()` describes how similar each node is to its alters +#' on some attribute, or, in a two-mode network, to those it shares a node +#' of the other mode with. +#' +#' Where the corresponding `node_by_*()` measures collapse this information +#' into a single score per node, these return the whole table, +#' which is often what is wanted when exploring ego-networks. +#' Each branches internally on the type of network or attribute given, +#' so the same function serves weighted, multiplex, and two-mode networks, +#' and categorical as well as continuous attributes. +#' @template param_data +#' @template param_attr +#' @template param_dir +#' @family motifs +#' @family diversity +#' @template node_motif +NULL + +#' @rdname motif_composition +#' @section Tie composition: +#' For a weighted network this returns the distribution of each node's tie +#' values: how many ties it has, and the sum, mean, standard deviation, +#' and quartiles of their strengths. +#' Two nodes may have the same weighted degree while one spreads its +#' involvement evenly and the other concentrates it in a single strong tie, +#' and it is the spread rather than the total that distinguishes them. +#' +#' For a multiplex network it instead returns one column per layer, giving +#' each node's degree in that layer (or its strength, where the layer is +#' itself weighted), together with `Diversity`, +#' the index of qualitative variation across the layers. +#' This is 0 where a node's ties all fall in a single layer, +#' and 1 where they are spread evenly across all of them. +#' Where the interest is in just two of the layers, +#' [node_by_multidegree()] gives the ratio between them. +#' +#' For an unweighted, uniplex network only the degree is available, +#' so this returns that alone. +#' Isolates have no ties to summarise and so take `NA` for the +#' distributional columns. +#' +#' In a directed network, `direction` selects whose ties are described: +#' a node's outgoing ties, its incoming ties, or both together. +#' Note that under `"all"` a reciprocated pair is treated as a single +#' relationship of combined strength, so `Ties` counts a node's distinct +#' alters rather than its arcs, while `Sum` matches its total degree. +#' @examples +#' node_x_ties(ison_networkers) +#' node_x_ties(ison_algebra) +#' @export +node_x_ties <- function(.data, direction = c("all", "out", "in")){ + .data <- manynet::expect_nodes(.data) + direction <- match.arg(direction) + if(manynet::is_multiplex(.data)){ + layers <- unique(manynet::tie_attribute(.data, "type")) + out <- vapply(layers, function(l) + as.numeric(node_by_degree(manynet::to_uniplex(.data, l), + normalized = FALSE, direction = direction)), + FUN.VALUE = numeric(manynet::net_nodes(.data))) + out <- cbind(out, Diversity = .iqv(out)) + } else if(manynet::is_weighted(.data)){ + mat <- .directed_matrix(.data, direction) + diag(mat) <- NA # a node's tie to itself is not part of its composition + out <- t(vapply(seq_len(nrow(mat)), function(i){ + vals <- mat[i,][!is.na(mat[i,])] + vals <- vals[vals != 0] # only realised ties have a strength + if(length(vals) == 0) + return(c(0, 0, rep(NA_real_, 6))) + c(length(vals), sum(vals), mean(vals), + stats::sd(vals), min(vals), + stats::median(vals), max(vals), + stats::IQR(vals)) + }, FUN.VALUE = numeric(8))) + colnames(out) <- c("Ties", "Sum", "Mean", "SD", + "Min", "Median", "Max", "IQR") + } else { + out <- matrix(as.numeric(node_by_degree(.data, normalized = FALSE, + direction = direction)), + ncol = 1, dimnames = list(NULL, "Ties")) + manynet::snet_info("Since this network is neither weighted nor multiplex,", + "only nodes' degrees are reported.") + } + make_node_motif(out, .data) +} + +# Orient a network's matrix so that each row holds the ties a node is to be +# described by: its outgoing ties, its incoming ties, or both. For an +# undirected network all three coincide. +.directed_matrix <- function(.data, direction){ + mat <- manynet::as_matrix(manynet::to_multilevel(.data)) + if(!manynet::is_directed(.data)) return(mat) + switch(direction, + out = mat, + `in` = t(mat), + all = mat + t(mat)) +} + +# Index of qualitative variation across the columns of a matrix of counts, +# normalising Blau's index by its maximum so that it ranges over [0,1] +# regardless of how many categories there are. Rows summing to zero have no +# distribution to describe and so return NA. +.iqv <- function(counts){ + k <- ncol(counts) + tot <- rowSums(counts) + props <- counts/tot + blau <- 1 - rowSums(props^2) + out <- if(k > 1) blau/(1 - 1/k) else rep(0, nrow(counts)) + out[tot == 0] <- NA_real_ + out +} + +#' @rdname motif_composition +#' @section Alter composition: +#' Where the attribute is categorical, this returns how many of each node's +#' alters fall into each category, weighted by tie strength where the network +#' is weighted. +#' Where it is continuous, this returns the sum, mean, tie-strength weighted +#' mean, minimum, maximum, range, and standard deviation of the attribute +#' across each node's alters. +#' +#' The weighted mean differs from the mean wherever a node's ties are of +#' unequal strength: it describes the attribute of the alters a node is most +#' involved with, rather than of its alters as an undifferentiated set. +#' Isolates have no alters and so take `NA`. +#' +#' Any tie counts as a tie here, whatever its sign. Apply +#' [manynet::to_unsigned()] first to consider only positive or only negative +#' ties. +#' @examples +#' node_x_alters(ison_networkers, "Discipline") +#' node_x_alters(ison_networkers, "Citations") +#' @export +node_x_alters <- function(.data, attribute){ + .data <- manynet::expect_nodes(.data) + attr <- .resolve_attribute(.data, attribute) + mat <- manynet::as_matrix(manynet::to_multilevel(.data)) + diag(mat) <- 0 # a node is not its own alter + if(.is_categorical(attr)){ + attr <- as.factor(attr) + out <- vapply(levels(attr), function(l) + rowSums(mat[, attr == l, drop = FALSE], na.rm = TRUE), + FUN.VALUE = numeric(nrow(mat))) + colnames(out) <- levels(attr) + } else { + attr <- as.numeric(attr) + out <- t(vapply(seq_len(nrow(mat)), function(i){ + w <- mat[i,] + alters <- attr[w != 0 & !is.na(w)] + wts <- w[w != 0 & !is.na(w)] + if(length(alters) == 0) return(rep(NA_real_, 7)) + c(sum(alters), mean(alters), + stats::weighted.mean(alters, wts), + min(alters), max(alters), diff(range(alters)), + stats::sd(alters)) + }, FUN.VALUE = numeric(7))) + colnames(out) <- c("Sum", "Mean", "Weighted", + "Min", "Max", "Range", "SD") + } + make_node_motif(out, .data) +} + +#' @rdname motif_composition +#' @section Ego-alter similarity: +#' Where the attribute is categorical, this returns each node's own two-by-two +#' table of whether a tie is present and whether the alter shares its +#' category, together with the summaries built from it: +#' the proportion of a node's ties that are to others of the same category +#' (`PctSame`), the EI index (`EI`), which runs from -1 where all of a node's +#' ties are internal to its own category to +1 where all are external, +#' the odds ratio and its logarithm, and Yule's Q. +#' +#' The EI index and the odds ratio answer different questions. +#' EI describes the mix of a node's ties, and so is sensitive to how large its +#' category is: in a small category even an indifferent node will have mostly +#' external ties. The odds ratio and Yule's Q instead compare the ties a node +#' made against the ties it could have made, and so are not. +#' +#' Where the attribute is continuous, this returns the mean difference, +#' mean absolute difference, and mean squared difference between a node and +#' its alters, followed by three measures of dyadic similarity averaged over +#' a node's alters: Zegers' coefficient, the ratio of the smaller value to the +#' larger, and the product. +#' @section Tertius similarity: +#' In a two-mode network no two nodes of the same mode are ever tied, +#' so similarity to one's alters cannot be measured directly. +#' Instead, each node is compared here with those it shares a node of the +#' other mode with, that is, its alters at distance two. +#' This is the tertius neighbourhood used by the `tertius()` effect in +#' `{migraph}` and `{goldfish}`, and described in Haunss and Hollway (2023): +#' in a discourse network, for example, the actors an actor is compared with +#' are those making claims about the same concepts. +#' +#' The same columns are returned as for a one-mode network, +#' but read at distance two: a node's alters are those it shares some +#' other-mode node with, however many they share, and the non-alters are +#' the remaining nodes of its own mode. +#' Nodes of the other mode are neither alters nor non-alters, +#' and so are excluded rather than counted as absent ties. +#' Since a node's alters are always of its own mode, +#' only that mode's values of the attribute are used; +#' where an attribute is held by one mode alone, +#' the other mode's nodes take `NA`. +#' @references +#' ## On tertius effects +#' Haunss, Sebastian, and James Hollway. 2023. +#' "Multimodal mechanisms of political discourse dynamics and the case of +#' Germany's nuclear energy phase-out". +#' _Network Science_ 11(2): 205-223. +#' \doi{10.1017/nws.2022.31} +#' +#' ## On the EI index +#' Krackhardt, David, and Robert N. Stern. 1988. +#' "Informal Networks and Organizational Crises: An Experimental Simulation". +#' _Social Psychology Quarterly_ 51(2): 123-140. +#' \doi{10.2307/2786835} +#' +#' ## On Yule's Q +#' Yule, G. Udny. 1912. +#' "On the Methods of Measuring Association Between Two Attributes". +#' _Journal of the Royal Statistical Society_ 75(6): 579-652. +#' \doi{10.2307/2340126} +#' @examples +#' node_x_similarity(ison_networkers, "Discipline") +#' node_x_similarity(ison_southern_women, "Title") +#' @export +node_x_similarity <- function(.data, attribute){ + .data <- manynet::expect_nodes(.data) + attr <- .resolve_attribute(.data, attribute) + mat <- .comparable_matrix(.data) + if(.is_categorical(attr)){ + same <- outer(attr, attr, "==") + same[is.na(mat)] <- NA # nodes that cannot be alters are not compared + out <- t(vapply(seq_len(nrow(mat)), function(i){ + a <- sum(mat[i,] == 1 & same[i,], na.rm = TRUE) + b <- sum(mat[i,] == 1 & !same[i,], na.rm = TRUE) + cc <- sum(mat[i,] == 0 & same[i,], na.rm = TRUE) + d <- sum(mat[i,] == 0 & !same[i,], na.rm = TRUE) + pct <- if((a+b) == 0) NA_real_ else a/(a+b) + ei <- if((a+b) == 0) NA_real_ else (b-a)/(b+a) + odds <- if(b*cc == 0) NA_real_ else (a*d)/(b*cc) + yule <- if((a*d + b*cc) == 0) NA_real_ else (a*d - b*cc)/(a*d + b*cc) + c(a, b, cc, d, pct, ei, odds, log(odds), yule) + }, FUN.VALUE = numeric(9))) + colnames(out) <- c("TieSame", "TieDiff", "NoTieSame", "NoTieDiff", + "PctSame", "EI", "Odds", "LogOdds", "YulesQ") + } else { + attr <- as.numeric(attr) + diffs <- outer(attr, attr, "-") # ego minus alter + zeg <- outer(attr, attr, function(x, y) + ifelse(x^2 + y^2 == 0, NA_real_, (x*y)/(x^2 + y^2))) + mnmx <- outer(attr, attr, function(x, y) + ifelse(pmax(x, y) == 0, NA_real_, pmin(x, y)/pmax(x, y))) + prod <- outer(attr, attr, "*") + out <- t(vapply(seq_len(nrow(mat)), function(i){ + alters <- which(mat[i,] == 1) + if(length(alters) == 0) return(rep(NA_real_, 6)) + c(mean(diffs[i, alters]), + mean(abs(diffs[i, alters])), + mean(diffs[i, alters]^2), + mean(zeg[i, alters], na.rm = TRUE), + mean(mnmx[i, alters], na.rm = TRUE), + mean(prod[i, alters])) + }, FUN.VALUE = numeric(6))) + colnames(out) <- c("Diff", "AbsDiff", "SqDiff", + "Zegers", "MinMax", "Product") + } + make_node_motif(out, .data) +} + +# Network-level homophily #### + +#' Motifs of network homophily +#' @name motif_homophily +#' @description +#' `net_x_homophily()` returns the two-by-two table from which network-level +#' homophily is calculated, together with the summaries built from it. +#' +#' Where [net_by_heterophily()] returns the EI index alone, +#' this returns the counts it rests on, so that the index can be interpreted +#' against the network's own composition. +#' +#' Note that on a weighted network the two report different values. +#' A contingency table counts ties, so `net_x_homophily()` treats every tie +#' alike, whereas [net_by_heterophily()] sums tie weights and so gives more +#' say to stronger ties. On unweighted networks the two agree exactly. +#' Apply [manynet::to_unweighted()] first to compare them directly. +#' @template param_data +#' @template param_attr +#' @family motifs +#' @family diversity +#' @template net_motif +#' @section Expected EI: +#' The EI index depends on how large the categories are, not only on how +#' nodes choose between them. +#' A network split into two equal groups will have a lower EI than one in +#' which a small minority is surrounded by a large majority, +#' even if nodes in both are equally indifferent to category. +#' +#' `ExpectedEI` gives the EI that would be observed if ties were distributed +#' at random across all possible pairs, holding category sizes fixed. +#' Comparing `EI` against it separates the network's mixing from its +#' composition: an EI above the expected value indicates more crossing of +#' category boundaries than chance alone would produce, and one below it +#' indicates less. +#' @references +#' ## On the EI index +#' Krackhardt, David, and Robert N. Stern. 1988. +#' "Informal Networks and Organizational Crises: An Experimental Simulation". +#' _Social Psychology Quarterly_ 51(2): 123-140. +#' \doi{10.2307/2786835} +#' @examples +#' net_x_homophily(ison_networkers, "Discipline") +#' @export +net_x_homophily <- function(.data, attribute){ + .data <- manynet::expect_nodes(.data) + if(manynet::is_twomode(.data)) + manynet::snet_abort("Homophily is only defined for one-mode networks.") + attr <- .resolve_attribute(.data, attribute) + if(!.is_categorical(attr)) attr <- as.factor(attr) + mat <- manynet::as_matrix(manynet::to_unweighted(.data)) + diag(mat) <- NA # self-ties are not homophilous + same <- outer(attr, attr, "==") + diag(same) <- NA + a <- sum(mat != 0 & same, na.rm = TRUE) + b <- sum(mat != 0 & !same, na.rm = TRUE) + cc <- sum(mat == 0 & same, na.rm = TRUE) + d <- sum(mat == 0 & !same, na.rm = TRUE) + ei <- if((a+b) == 0) NaN else (b-a)/(a+b) + # the EI expected if the same number of ties were placed at random + # over all possible pairs, holding the category sizes fixed + expei <- if((a+b+cc+d) == 0) NaN else ((b+d) - (a+cc))/(a+b+cc+d) + yule <- if((a*d + b*cc) == 0) NaN else (a*d - b*cc)/(a*d + b*cc) + out <- c(TieSame = a, TieDiff = b, NoTieSame = cc, NoTieDiff = d, + PctSame = if((a+b) == 0) NaN else a/(a+b), + EI = ei, ExpectedEI = expei, YulesQ = yule) + make_network_motif(out, .data) +} + +# Helpers #### + +# Which nodes each node is compared with, as a matrix of 1 where a node is an +# alter, 0 where it could have been but is not, and NA where it could not be. +# In a one-mode network a node's alters are simply those it is tied to. In a +# two-mode network no two nodes of the same mode are ever tied, so the nearest +# comparable others are those at distance two: those a node shares a node of +# the other mode with. Nodes of the other mode are then neither alters nor +# non-alters, and so are held out rather than counted as absent ties. +.comparable_matrix <- function(.data){ + mat <- manynet::as_matrix( + manynet::to_unweighted(manynet::to_multilevel(.data))) + mat[mat != 0] <- 1 + if(manynet::is_twomode(.data)){ + mat <- (mat %*% mat > 0) * 1 # shares at least one node of the other mode + mode <- manynet::node_is_mode(.data) + mat[outer(mode, mode, "!=")] <- NA + } + diag(mat) <- NA # a node is not its own alter + mat +} + +# Resolve an attribute given either as a name or as a vector, matching how +# `node_by_heterophily()` and friends accept either. +.resolve_attribute <- function(.data, attribute){ + if(length(attribute) == 1 && is.character(attribute)) + attribute <- manynet::node_attribute(.data, attribute) + if(is.null(attribute)) + manynet::snet_abort("No such attribute found in this network.") + if(length(attribute) != manynet::net_nodes(.data)) + manynet::snet_abort("`attribute` must be as long as there are nodes.") + attribute +} + +# Character, factor and logical attributes are categorical; numeric ones are +# treated as continuous. Since group codes are often stored as numbers, which +# branch was taken is reported rather than left to be inferred from the output. +.is_categorical <- function(attribute){ + out <- is.character(attribute) || is.factor(attribute) || is.logical(attribute) + if(!out && is.numeric(attribute) && + length(unique(stats::na.omit(attribute))) < 10) + manynet::snet_info( + "Treating this numeric attribute as continuous.", + "If it codes categories, pass it {.code as.factor()} instead.") + out +} diff --git a/man/mark_core.Rd b/man/mark_core.Rd index 5387d1a..9600f00 100644 --- a/man/mark_core.Rd +++ b/man/mark_core.Rd @@ -101,6 +101,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/mark_degree.Rd b/man/mark_degree.Rd index 30b385c..e0d6d23 100644 --- a/man/mark_degree.Rd +++ b/man/mark_degree.Rd @@ -92,6 +92,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/mark_diff.Rd b/man/mark_diff.Rd index 3cb5950..668835f 100644 --- a/man/mark_diff.Rd +++ b/man/mark_diff.Rd @@ -100,6 +100,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/mark_nodes.Rd b/man/mark_nodes.Rd index faa37ef..8ad61a0 100644 --- a/man/mark_nodes.Rd +++ b/man/mark_nodes.Rd @@ -133,6 +133,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/mark_select_node.Rd b/man/mark_select_node.Rd index 56daa74..84d0941 100644 --- a/man/mark_select_node.Rd +++ b/man/mark_select_node.Rd @@ -93,6 +93,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/measure_assort_net.Rd b/man/measure_assort_net.Rd index 0b1ea7c..fdc480b 100644 --- a/man/measure_assort_net.Rd +++ b/man/measure_assort_net.Rd @@ -140,7 +140,9 @@ Moran, Patrick Alfred Pierce. 1950. Other diversity: \code{\link{measure_assort_node}}, \code{\link{measure_diverse_net}}, -\code{\link{measure_diverse_node}} +\code{\link{measure_diverse_node}}, +\code{\link{motif_composition}}, +\code{\link{motif_homophily}} Other measures: \code{\link{measure_assort_node}}, diff --git a/man/measure_assort_node.Rd b/man/measure_assort_node.Rd index 8517841..eadf939 100644 --- a/man/measure_assort_node.Rd +++ b/man/measure_assort_node.Rd @@ -80,7 +80,9 @@ node_by_heterophily(marvel_friends, "Attractive") Other diversity: \code{\link{measure_assort_net}}, \code{\link{measure_diverse_net}}, -\code{\link{measure_diverse_node}} +\code{\link{measure_diverse_node}}, +\code{\link{motif_composition}}, +\code{\link{motif_homophily}} Other measures: \code{\link{measure_assort_net}}, @@ -136,6 +138,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/measure_broker_node.Rd b/man/measure_broker_node.Rd index 97cc22a..122725c 100644 --- a/man/measure_broker_node.Rd +++ b/man/measure_broker_node.Rd @@ -171,6 +171,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/measure_brokerage.Rd b/man/measure_brokerage.Rd index 6b9b770..35fbebe 100644 --- a/man/measure_brokerage.Rd +++ b/man/measure_brokerage.Rd @@ -104,6 +104,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/measure_central_between.Rd b/man/measure_central_between.Rd index 84dedfa..4e78457 100644 --- a/man/measure_central_between.Rd +++ b/man/measure_central_between.Rd @@ -199,6 +199,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/measure_central_degree.Rd b/man/measure_central_degree.Rd index 7497e33..e10e71f 100644 --- a/man/measure_central_degree.Rd +++ b/man/measure_central_degree.Rd @@ -232,6 +232,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/measure_central_eigen.Rd b/man/measure_central_eigen.Rd index d2ec51c..12ecb34 100644 --- a/man/measure_central_eigen.Rd +++ b/man/measure_central_eigen.Rd @@ -278,6 +278,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/measure_closure_node.Rd b/man/measure_closure_node.Rd index 1444fb1..1974b15 100644 --- a/man/measure_closure_node.Rd +++ b/man/measure_closure_node.Rd @@ -100,6 +100,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/measure_core.Rd b/man/measure_core.Rd index a8910ac..3d7d6c5 100644 --- a/man/measure_core.Rd +++ b/man/measure_core.Rd @@ -115,6 +115,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/measure_diffusion_node.Rd b/man/measure_diffusion_node.Rd index 9fab2dc..219b504 100644 --- a/man/measure_diffusion_node.Rd +++ b/man/measure_diffusion_node.Rd @@ -183,6 +183,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/measure_diverse_net.Rd b/man/measure_diverse_net.Rd index 2ea5dd1..2947fc2 100644 --- a/man/measure_diverse_net.Rd +++ b/man/measure_diverse_net.Rd @@ -140,7 +140,9 @@ Princeton: Princeton University Press. Other diversity: \code{\link{measure_assort_net}}, \code{\link{measure_assort_node}}, -\code{\link{measure_diverse_node}} +\code{\link{measure_diverse_node}}, +\code{\link{motif_composition}}, +\code{\link{motif_homophily}} Other measures: \code{\link{measure_assort_net}}, diff --git a/man/measure_diverse_node.Rd b/man/measure_diverse_node.Rd index 5ad9ccb..d012f7c 100644 --- a/man/measure_diverse_node.Rd +++ b/man/measure_diverse_node.Rd @@ -55,7 +55,9 @@ node_by_diversity(marvel_friends, "Attractive") Other diversity: \code{\link{measure_assort_net}}, \code{\link{measure_assort_node}}, -\code{\link{measure_diverse_net}} +\code{\link{measure_diverse_net}}, +\code{\link{motif_composition}}, +\code{\link{motif_homophily}} Other measures: \code{\link{measure_assort_net}}, @@ -111,6 +113,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/member_brokerage.Rd b/man/member_brokerage.Rd index 695cc59..23c7684 100644 --- a/man/member_brokerage.Rd +++ b/man/member_brokerage.Rd @@ -55,7 +55,8 @@ Other memberships: \code{\link{member_components}}, \code{\link{member_core}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}} +\code{\link{member_equivalence}}, +\code{\link{method_equivalence}} Other nodal: \code{\link{mark_core}}, @@ -83,6 +84,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/member_cliques.Rd b/man/member_cliques.Rd index 5328a03..b6b46d7 100644 --- a/man/member_cliques.Rd +++ b/man/member_cliques.Rd @@ -87,7 +87,8 @@ Other memberships: \code{\link{member_components}}, \code{\link{member_core}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}} +\code{\link{member_equivalence}}, +\code{\link{method_equivalence}} Other nodal: \code{\link{mark_core}}, @@ -115,6 +116,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/member_community.Rd b/man/member_community.Rd index 9d0debb..1c1fd18 100644 --- a/man/member_community.Rd +++ b/man/member_community.Rd @@ -42,7 +42,8 @@ Other memberships: \code{\link{member_components}}, \code{\link{member_core}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}} +\code{\link{member_equivalence}}, +\code{\link{method_equivalence}} Other nodal: \code{\link{mark_core}}, @@ -70,6 +71,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/member_community_hier.Rd b/man/member_community_hier.Rd index f523d67..f8d1360 100644 --- a/man/member_community_hier.Rd +++ b/man/member_community_hier.Rd @@ -137,7 +137,8 @@ Other memberships: \code{\link{member_components}}, \code{\link{member_core}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}} +\code{\link{member_equivalence}}, +\code{\link{method_equivalence}} Other nodal: \code{\link{mark_core}}, @@ -165,6 +166,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/member_components.Rd b/man/member_components.Rd index dfba814..8cc214d 100644 --- a/man/member_components.Rd +++ b/man/member_components.Rd @@ -59,7 +59,8 @@ Other memberships: \code{\link{member_community_non}}, \code{\link{member_core}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}} +\code{\link{member_equivalence}}, +\code{\link{method_equivalence}} Other nodal: \code{\link{mark_core}}, @@ -87,6 +88,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/member_core.Rd b/man/member_core.Rd index 0c0e098..54e1aa5 100644 --- a/man/member_core.Rd +++ b/man/member_core.Rd @@ -64,7 +64,8 @@ Other memberships: \code{\link{member_community_non}}, \code{\link{member_components}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}} +\code{\link{member_equivalence}}, +\code{\link{method_equivalence}} Other nodal: \code{\link{mark_core}}, @@ -92,6 +93,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/member_diffusion.Rd b/man/member_diffusion.Rd index b3b21af..06a7969 100644 --- a/man/member_diffusion.Rd +++ b/man/member_diffusion.Rd @@ -61,7 +61,8 @@ Other memberships: \code{\link{member_community_non}}, \code{\link{member_components}}, \code{\link{member_core}}, -\code{\link{member_equivalence}} +\code{\link{member_equivalence}}, +\code{\link{method_equivalence}} Other nodal: \code{\link{mark_core}}, @@ -89,6 +90,8 @@ Other nodal: \code{\link{member_core}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/motif_brokerage_net.Rd b/man/motif_brokerage_net.Rd index 07900eb..fae7222 100644 --- a/man/motif_brokerage_net.Rd +++ b/man/motif_brokerage_net.Rd @@ -46,9 +46,12 @@ Other brokerage: Other motifs: \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_hazard}}, \code{\link{motif_hierarchy}}, +\code{\link{motif_homophily}}, \code{\link{motif_net}}, \code{\link{motif_node}}, \code{\link{motif_path}}, diff --git a/man/motif_brokerage_node.Rd b/man/motif_brokerage_node.Rd index b75e921..7f76302 100644 --- a/man/motif_brokerage_node.Rd +++ b/man/motif_brokerage_node.Rd @@ -62,9 +62,12 @@ Other brokerage: Other motifs: \code{\link{motif_brokerage_net}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_hazard}}, \code{\link{motif_hierarchy}}, +\code{\link{motif_homophily}}, \code{\link{motif_net}}, \code{\link{motif_node}}, \code{\link{motif_path}}, @@ -96,6 +99,8 @@ Other nodal: \code{\link{member_core}}, \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/motif_composition.Rd b/man/motif_composition.Rd new file mode 100644 index 0000000..eecbe4e --- /dev/null +++ b/man/motif_composition.Rd @@ -0,0 +1,239 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/motif_composition.R +\name{motif_composition} +\alias{motif_composition} +\alias{node_x_ties} +\alias{node_x_alters} +\alias{node_x_similarity} +\title{Motifs of ego-network composition} +\usage{ +node_x_ties(.data, direction = c("all", "out", "in")) + +node_x_alters(.data, attribute) + +node_x_similarity(.data, attribute) +} +\arguments{ +\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. +For more information on the standard coercion possible, +see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} + +\item{direction}{Character string, “out” bases the measure on outgoing ties, +“in” on incoming ties, and "all" on either/the sum of the two. +By default "all".} + +\item{attribute}{Name of a nodal attribute, mark, measure, or membership vector.} +} +\value{ +A \code{node_motif} matrix with one row for each node in the network and +a column for each motif type, +giving the count of each motif in which each node participates. +It is printed as a tibble, however, to avoid greedy printing. +If the network is labelled, +then the node names will be in a column named \code{names}. +} +\description{ +These functions describe the composition of each node's ego-network, +that is, what the ties and alters surrounding each node look like: +\itemize{ +\item \code{node_x_ties()} describes the distribution of each node's tie values, +or, in a multiplex network, how its ties are spread across layers. +\item \code{node_x_alters()} describes the composition of each node's alters on +some attribute. +\item \code{node_x_similarity()} describes how similar each node is to its alters +on some attribute, or, in a two-mode network, to those it shares a node +of the other mode with. +} + +Where the corresponding \verb{node_by_*()} measures collapse this information +into a single score per node, these return the whole table, +which is often what is wanted when exploring ego-networks. +Each branches internally on the type of network or attribute given, +so the same function serves weighted, multiplex, and two-mode networks, +and categorical as well as continuous attributes. +} +\section{Tie composition}{ + +For a weighted network this returns the distribution of each node's tie +values: how many ties it has, and the sum, mean, standard deviation, +and quartiles of their strengths. +Two nodes may have the same weighted degree while one spreads its +involvement evenly and the other concentrates it in a single strong tie, +and it is the spread rather than the total that distinguishes them. + +For a multiplex network it instead returns one column per layer, giving +each node's degree in that layer (or its strength, where the layer is +itself weighted), together with \code{Diversity}, +the index of qualitative variation across the layers. +This is 0 where a node's ties all fall in a single layer, +and 1 where they are spread evenly across all of them. +Where the interest is in just two of the layers, +\code{\link[=node_by_multidegree]{node_by_multidegree()}} gives the ratio between them. + +For an unweighted, uniplex network only the degree is available, +so this returns that alone. +Isolates have no ties to summarise and so take \code{NA} for the +distributional columns. + +In a directed network, \code{direction} selects whose ties are described: +a node's outgoing ties, its incoming ties, or both together. +Note that under \code{"all"} a reciprocated pair is treated as a single +relationship of combined strength, so \code{Ties} counts a node's distinct +alters rather than its arcs, while \code{Sum} matches its total degree. +} + +\section{Alter composition}{ + +Where the attribute is categorical, this returns how many of each node's +alters fall into each category, weighted by tie strength where the network +is weighted. +Where it is continuous, this returns the sum, mean, tie-strength weighted +mean, minimum, maximum, range, and standard deviation of the attribute +across each node's alters. + +The weighted mean differs from the mean wherever a node's ties are of +unequal strength: it describes the attribute of the alters a node is most +involved with, rather than of its alters as an undifferentiated set. +Isolates have no alters and so take \code{NA}. + +Any tie counts as a tie here, whatever its sign. Apply +\code{\link[manynet:to_unsigned]{manynet::to_unsigned()}} first to consider only positive or only negative +ties. +} + +\section{Ego-alter similarity}{ + +Where the attribute is categorical, this returns each node's own two-by-two +table of whether a tie is present and whether the alter shares its +category, together with the summaries built from it: +the proportion of a node's ties that are to others of the same category +(\code{PctSame}), the EI index (\code{EI}), which runs from -1 where all of a node's +ties are internal to its own category to +1 where all are external, +the odds ratio and its logarithm, and Yule's Q. + +The EI index and the odds ratio answer different questions. +EI describes the mix of a node's ties, and so is sensitive to how large its +category is: in a small category even an indifferent node will have mostly +external ties. The odds ratio and Yule's Q instead compare the ties a node +made against the ties it could have made, and so are not. + +Where the attribute is continuous, this returns the mean difference, +mean absolute difference, and mean squared difference between a node and +its alters, followed by three measures of dyadic similarity averaged over +a node's alters: Zegers' coefficient, the ratio of the smaller value to the +larger, and the product. +} + +\section{Tertius similarity}{ + +In a two-mode network no two nodes of the same mode are ever tied, +so similarity to one's alters cannot be measured directly. +Instead, each node is compared here with those it shares a node of the +other mode with, that is, its alters at distance two. +This is the tertius neighbourhood used by the \code{tertius()} effect in +\code{{migraph}} and \code{{goldfish}}, and described in Haunss and Hollway (2023): +in a discourse network, for example, the actors an actor is compared with +are those making claims about the same concepts. + +The same columns are returned as for a one-mode network, +but read at distance two: a node's alters are those it shares some +other-mode node with, however many they share, and the non-alters are +the remaining nodes of its own mode. +Nodes of the other mode are neither alters nor non-alters, +and so are excluded rather than counted as absent ties. +Since a node's alters are always of its own mode, +only that mode's values of the attribute are used; +where an attribute is held by one mode alone, +the other mode's nodes take \code{NA}. +} + +\examples{ +node_x_ties(ison_networkers) +node_x_ties(ison_algebra) +node_x_alters(ison_networkers, "Discipline") +node_x_alters(ison_networkers, "Citations") +node_x_similarity(ison_networkers, "Discipline") +node_x_similarity(ison_southern_women, "Title") +} +\references{ +\subsection{On tertius effects}{ + +Haunss, Sebastian, and James Hollway. 2023. +"Multimodal mechanisms of political discourse dynamics and the case of +Germany's nuclear energy phase-out". +\emph{Network Science} 11(2): 205-223. +\doi{10.1017/nws.2022.31} +} + +\subsection{On the EI index}{ + +Krackhardt, David, and Robert N. Stern. 1988. +"Informal Networks and Organizational Crises: An Experimental Simulation". +\emph{Social Psychology Quarterly} 51(2): 123-140. +\doi{10.2307/2786835} +} + +\subsection{On Yule's Q}{ + +Yule, G. Udny. 1912. +"On the Methods of Measuring Association Between Two Attributes". +\emph{Journal of the Royal Statistical Society} 75(6): 579-652. +\doi{10.2307/2340126} +} +} +\seealso{ +Other motifs: +\code{\link{motif_brokerage_net}}, +\code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_exposure}}, +\code{\link{motif_hazard}}, +\code{\link{motif_hierarchy}}, +\code{\link{motif_homophily}}, +\code{\link{motif_net}}, +\code{\link{motif_node}}, +\code{\link{motif_path}}, +\code{\link{motif_periods}} + +Other diversity: +\code{\link{measure_assort_net}}, +\code{\link{measure_assort_node}}, +\code{\link{measure_diverse_net}}, +\code{\link{measure_diverse_node}}, +\code{\link{motif_homophily}} + +Other nodal: +\code{\link{mark_core}}, +\code{\link{mark_degree}}, +\code{\link{mark_diff}}, +\code{\link{mark_nodes}}, +\code{\link{mark_select_node}}, +\code{\link{measure_assort_node}}, +\code{\link{measure_broker_node}}, +\code{\link{measure_brokerage}}, +\code{\link{measure_central_between}}, +\code{\link{measure_central_close}}, +\code{\link{measure_central_degree}}, +\code{\link{measure_central_eigen}}, +\code{\link{measure_closure_node}}, +\code{\link{measure_core}}, +\code{\link{measure_diffusion_node}}, +\code{\link{measure_diverse_node}}, +\code{\link{member_brokerage}}, +\code{\link{member_cliques}}, +\code{\link{member_community}}, +\code{\link{member_community_hier}}, +\code{\link{member_community_non}}, +\code{\link{member_components}}, +\code{\link{member_core}}, +\code{\link{member_diffusion}}, +\code{\link{member_equivalence}}, +\code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_exposure}}, +\code{\link{motif_node}}, +\code{\link{motif_path}} +} +\concept{diversity} +\concept{motifs} +\concept{nodal} diff --git a/man/motif_exposure.Rd b/man/motif_exposure.Rd index df3cde2..a836fe2 100644 --- a/man/motif_exposure.Rd +++ b/man/motif_exposure.Rd @@ -39,8 +39,11 @@ Other diffusion: Other motifs: \code{\link{motif_brokerage_net}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_hazard}}, \code{\link{motif_hierarchy}}, +\code{\link{motif_homophily}}, \code{\link{motif_net}}, \code{\link{motif_node}}, \code{\link{motif_path}}, @@ -73,6 +76,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_node}}, \code{\link{motif_path}} } diff --git a/man/motif_hazard.Rd b/man/motif_hazard.Rd index 55abf68..cc5f6c6 100644 --- a/man/motif_hazard.Rd +++ b/man/motif_hazard.Rd @@ -98,8 +98,11 @@ Other diffusion: Other motifs: \code{\link{motif_brokerage_net}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_hierarchy}}, +\code{\link{motif_homophily}}, \code{\link{motif_net}}, \code{\link{motif_node}}, \code{\link{motif_path}}, diff --git a/man/motif_hierarchy.Rd b/man/motif_hierarchy.Rd index 0fa2106..63c1300 100644 --- a/man/motif_hierarchy.Rd +++ b/man/motif_hierarchy.Rd @@ -58,8 +58,11 @@ Other hierarchy: Other motifs: \code{\link{motif_brokerage_net}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_hazard}}, +\code{\link{motif_homophily}}, \code{\link{motif_net}}, \code{\link{motif_node}}, \code{\link{motif_path}}, diff --git a/man/motif_homophily.Rd b/man/motif_homophily.Rd new file mode 100644 index 0000000..6b79bc2 --- /dev/null +++ b/man/motif_homophily.Rd @@ -0,0 +1,87 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/motif_composition.R +\name{motif_homophily} +\alias{motif_homophily} +\alias{net_x_homophily} +\title{Motifs of network homophily} +\usage{ +net_x_homophily(.data, attribute) +} +\arguments{ +\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. +For more information on the standard coercion possible, +see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} + +\item{attribute}{Name of a nodal attribute, mark, measure, or membership vector.} +} +\value{ +A \code{network_motif} named numeric vector or sometimes a data frame with +one row and a column for each motif type, +giving the count of each motif in the network. +This is printed as a tibble to avoid greedy printing of long vectors. +} +\description{ +\code{net_x_homophily()} returns the two-by-two table from which network-level +homophily is calculated, together with the summaries built from it. + +Where \code{\link[=net_by_heterophily]{net_by_heterophily()}} returns the EI index alone, +this returns the counts it rests on, so that the index can be interpreted +against the network's own composition. + +Note that on a weighted network the two report different values. +A contingency table counts ties, so \code{net_x_homophily()} treats every tie +alike, whereas \code{\link[=net_by_heterophily]{net_by_heterophily()}} sums tie weights and so gives more +say to stronger ties. On unweighted networks the two agree exactly. +Apply \code{\link[manynet:to_unweighted]{manynet::to_unweighted()}} first to compare them directly. +} +\section{Expected EI}{ + +The EI index depends on how large the categories are, not only on how +nodes choose between them. +A network split into two equal groups will have a lower EI than one in +which a small minority is surrounded by a large majority, +even if nodes in both are equally indifferent to category. + +\code{ExpectedEI} gives the EI that would be observed if ties were distributed +at random across all possible pairs, holding category sizes fixed. +Comparing \code{EI} against it separates the network's mixing from its +composition: an EI above the expected value indicates more crossing of +category boundaries than chance alone would produce, and one below it +indicates less. +} + +\examples{ +net_x_homophily(ison_networkers, "Discipline") +} +\references{ +\subsection{On the EI index}{ + +Krackhardt, David, and Robert N. Stern. 1988. +"Informal Networks and Organizational Crises: An Experimental Simulation". +\emph{Social Psychology Quarterly} 51(2): 123-140. +\doi{10.2307/2786835} +} +} +\seealso{ +Other motifs: +\code{\link{motif_brokerage_net}}, +\code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, +\code{\link{motif_exposure}}, +\code{\link{motif_hazard}}, +\code{\link{motif_hierarchy}}, +\code{\link{motif_net}}, +\code{\link{motif_node}}, +\code{\link{motif_path}}, +\code{\link{motif_periods}} + +Other diversity: +\code{\link{measure_assort_net}}, +\code{\link{measure_assort_node}}, +\code{\link{measure_diverse_net}}, +\code{\link{measure_diverse_node}}, +\code{\link{motif_composition}} +} +\concept{diversity} +\concept{motifs} diff --git a/man/motif_net.Rd b/man/motif_net.Rd index a861504..46c6053 100644 --- a/man/motif_net.Rd +++ b/man/motif_net.Rd @@ -182,9 +182,12 @@ Other cohesion: Other motifs: \code{\link{motif_brokerage_net}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_hazard}}, \code{\link{motif_hierarchy}}, +\code{\link{motif_homophily}}, \code{\link{motif_node}}, \code{\link{motif_path}}, \code{\link{motif_periods}} diff --git a/man/motif_node.Rd b/man/motif_node.Rd index 38878b3..b780ad5 100644 --- a/man/motif_node.Rd +++ b/man/motif_node.Rd @@ -114,9 +114,12 @@ Other cohesion: Other motifs: \code{\link{motif_brokerage_net}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_hazard}}, \code{\link{motif_hierarchy}}, +\code{\link{motif_homophily}}, \code{\link{motif_net}}, \code{\link{motif_path}}, \code{\link{motif_periods}} @@ -148,6 +151,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_path}} } diff --git a/man/motif_path.Rd b/man/motif_path.Rd index 66fc660..6c56c5e 100644 --- a/man/motif_path.Rd +++ b/man/motif_path.Rd @@ -58,9 +58,12 @@ Opsahl, Tore, Filip Agneessens, and John Skvoretz. 2010. Other motifs: \code{\link{motif_brokerage_net}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_hazard}}, \code{\link{motif_hierarchy}}, +\code{\link{motif_homophily}}, \code{\link{motif_net}}, \code{\link{motif_node}}, \code{\link{motif_periods}} @@ -92,6 +95,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}} } diff --git a/man/motif_periods.Rd b/man/motif_periods.Rd index 4dde8b6..7ae8362 100644 --- a/man/motif_periods.Rd +++ b/man/motif_periods.Rd @@ -44,9 +44,12 @@ Other change: Other motifs: \code{\link{motif_brokerage_net}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_hazard}}, \code{\link{motif_hierarchy}}, +\code{\link{motif_homophily}}, \code{\link{motif_net}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/tests/testthat/test-motif_composition.R b/tests/testthat/test-motif_composition.R new file mode 100644 index 0000000..2b2350f --- /dev/null +++ b/tests/testthat/test-motif_composition.R @@ -0,0 +1,120 @@ +test_that("node_x_ties branches on network type", { + # weighted networks get the full distribution of tie values + ws <- node_x_ties(ison_networkers) + expect_s3_class(ws, "node_motif") + expect_equal(colnames(ws), c("Ties", "Sum", "Mean", "SD", + "Min", "Median", "Max", "IQR")) + # the sum of a node's tie values is its weighted degree + expect_equal(unname(ws[, "Sum"]), + as.numeric(node_by_degree(ison_networkers, normalized = FALSE))) + expect_true(all(ws[, "Min"] <= ws[, "Max"], na.rm = TRUE)) + + # multiplex networks get one column per layer, plus diversity + ms <- node_x_ties(ison_algebra) + expect_equal(ncol(ms), length(unique(tie_attribute(ison_algebra, "type"))) + 1) + expect_true("Diversity" %in% colnames(ms)) + expect_true(all(ms[, "Diversity"] >= 0 & ms[, "Diversity"] <= 1, na.rm = TRUE)) + + # plain networks have only degree to report + ps <- node_x_ties(ison_adolescents) + expect_equal(colnames(ps), "Ties") + expect_equal(unname(ps[, "Ties"]), + as.numeric(node_by_degree(ison_adolescents, normalized = FALSE))) +}) + +test_that("node_x_ties diversity behaves at its bounds", { + # ties concentrated in one layer are minimally diverse, + # ties spread evenly across layers are maximally so + expect_equal(unname(.iqv(matrix(c(4, 0, 0), 1, 3))), 0) + expect_equal(unname(.iqv(matrix(c(2, 2, 2), 1, 3))), 1) + # an isolate has no distribution to describe + expect_true(is.na(.iqv(matrix(c(0, 0, 0), 1, 3)))) +}) + +test_that("node_x_alters branches on attribute type", { + # categorical attributes give one column per category + cat_res <- node_x_alters(ison_networkers, "Discipline") + expect_equal(sort(colnames(cat_res)), + sort(unique(as.character(node_attribute(ison_networkers, + "Discipline"))))) + # continuous attributes give distributional summaries + con_res <- node_x_alters(ison_networkers, "Citations") + expect_equal(colnames(con_res), c("Sum", "Mean", "Weighted", + "Min", "Max", "Range", "SD")) + expect_true(all(con_res[, "Min"] <= con_res[, "Max"], na.rm = TRUE)) + expect_true(all(con_res[, "Range"] == + con_res[, "Max"] - con_res[, "Min"], na.rm = TRUE)) + # isolates have no alters to summarise + iso <- add_node_attribute(create_empty(4), "x", c(1, 2, 3, 4)) + expect_true(all(is.na(node_x_alters(iso, "x")[, "Mean"]))) +}) + +test_that("node_x_similarity branches on attribute type", { + cat_res <- node_x_similarity(ison_networkers, "Discipline") + expect_equal(colnames(cat_res), + c("TieSame", "TieDiff", "NoTieSame", "NoTieDiff", + "PctSame", "EI", "Odds", "LogOdds", "YulesQ")) + # the four cells partition every other node + expect_true(all(rowSums(cat_res[, 1:4]) == + manynet::net_nodes(ison_networkers) - 1)) + # the EI column is the node-level heterophily measure + expect_equal(unname(round(cat_res[, "EI"], 4)), + unname(round(as.numeric( + node_by_heterophily(to_unweighted(ison_networkers), + "Discipline")), 4))) + + con_res <- node_x_similarity(ison_networkers, "Citations") + expect_equal(colnames(con_res), c("Diff", "AbsDiff", "SqDiff", + "Zegers", "MinMax", "Product")) + expect_true(all(con_res[, "AbsDiff"] >= 0, na.rm = TRUE)) + expect_true(all(con_res[, "SqDiff"] >= 0, na.rm = TRUE)) + + expect_error(node_x_similarity(ison_networkers, "nonexistent")) +}) + +test_that("node_x_similarity compares two-mode nodes at distance two", { + res <- node_x_similarity(ison_southern_women, "Title") + expect_s3_class(res, "node_motif") + expect_equal(colnames(res), + c("TieSame", "TieDiff", "NoTieSame", "NoTieDiff", + "PctSame", "EI", "Odds", "LogOdds", "YulesQ")) + women <- !node_is_mode(ison_southern_women) + # only nodes of a node's own mode are counted, as alters or as non-alters + expect_true(all(rowSums(res[women, 1:4]) == sum(women) - 1)) + # the alters are those at distance two, not those tied + d2 <- to_unweighted(to_mode1(ison_southern_women)) + expect_equal(unname(res[women, "TieSame"] + res[women, "TieDiff"]), + as.numeric(node_by_degree(d2, normalized = FALSE))) + # the events hold no title of their own, so have nothing to compare on + expect_true(all(is.na(res[!women, "PctSame"]))) +}) + +test_that("net_x_homophily agrees with net_by_heterophily", { + res <- net_x_homophily(ison_adolescents, + rep(c("A", "B"), 4)) + expect_s3_class(res, "network_motif") + expect_equal(names(res), c("TieSame", "TieDiff", "NoTieSame", "NoTieDiff", + "PctSame", "EI", "ExpectedEI", "YulesQ")) + # on unweighted networks the EI column is exactly net_by_heterophily() + g <- add_node_attribute(ison_adolescents, "grp", rep(c("A", "B"), 4)) + expect_equal(unname(net_x_homophily(g, "grp")["EI"]), + as.numeric(net_by_heterophily(g, "grp"))) + uw <- to_unweighted(ison_networkers) + expect_equal(unname(net_x_homophily(uw, "Discipline")["EI"]), + as.numeric(net_by_heterophily(uw, "Discipline"))) + # but on weighted networks they differ, since one counts ties and the + # other sums weights + expect_false(isTRUE(all.equal( + unname(net_x_homophily(ison_networkers, "Discipline")["EI"]), + as.numeric(net_by_heterophily(ison_networkers, "Discipline"))))) + + expect_error(net_x_homophily(ison_southern_women, "type")) +}) + +test_that("attribute resolution accepts names and vectors alike", { + expect_equal(node_x_alters(ison_networkers, "Citations"), + node_x_alters(ison_networkers, + node_attribute(ison_networkers, "Citations"))) + expect_error(node_x_alters(ison_networkers, "nonexistent")) + expect_error(node_x_alters(ison_networkers, c(1, 2, 3))) +}) diff --git a/tests/testthat/test-motif_net.R b/tests/testthat/test-motif_net.R index cac986f..6408e4b 100644 --- a/tests/testthat/test-motif_net.R +++ b/tests/testthat/test-motif_net.R @@ -4,7 +4,9 @@ for(fn in names(net_motifs)) { test_that(paste(fn, "works on", ob), { skip_if(grepl("exposure|mixed|hazard", fn)) skip_if(grepl("triad", fn) && is_twomode(data_objs[[ob]])) - if(grepl("brokerage", fn)){ + # homophily is only defined against a one-mode attribute + skip_if(grepl("homophily", fn) && is_twomode(data_objs[[ob]])) + if(grepl("brokerage|homophily", fn)){ if(ob == "attribute") expect_s3_class(net_motifs[[fn]](data_objs[[ob]], "group"), "network_motif") else succeed("Only used for attribute objects") diff --git a/tests/testthat/test-motif_nodes.R b/tests/testthat/test-motif_nodes.R index 51315c2..82d85c8 100644 --- a/tests/testthat/test-motif_nodes.R +++ b/tests/testthat/test-motif_nodes.R @@ -3,7 +3,7 @@ for(fn in names(node_motifs)) { for (ob in names(data_objs)) { test_that(paste(fn, "works on", ob), { skip_if(grepl("triad|dyad", fn) && is_twomode(data_objs[[ob]])) - if(grepl("brokerage", fn)){ + if(grepl("brokerage|alters|similarity", fn)){ if(ob == "attribute") expect_s3_class(node_motifs[[fn]](data_objs[[ob]], "group"), "node_motif") else succeed("Only used for attribute objects") From 05b08cb6f9f4c228c154e2b34b14bb5dcc203ff7 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 08:19:33 +0200 Subject: [PATCH 08/68] Restructured website --- pkgdown/_pkgdown.yml | 12 ++++++++++-- pkgdown/favicon/apple-touch-icon.png | Bin 7055 -> 9445 bytes pkgdown/favicon/favicon-96x96.png | Bin 2889 -> 4627 bytes pkgdown/favicon/favicon.ico | Bin 15086 -> 15086 bytes pkgdown/favicon/favicon.svg | 2 +- pkgdown/favicon/web-app-manifest-192x192.png | Bin 7497 -> 9968 bytes pkgdown/favicon/web-app-manifest-512x512.png | Bin 23364 -> 35468 bytes 7 files changed, 11 insertions(+), 3 deletions(-) diff --git a/pkgdown/_pkgdown.yml b/pkgdown/_pkgdown.yml index 64a2f04..fed2dcf 100644 --- a/pkgdown/_pkgdown.yml +++ b/pkgdown/_pkgdown.yml @@ -97,18 +97,26 @@ reference: - subtitle: "Centrality" contents: - starts_with("measure_central") + - subtitle: "Brokerage" + contents: - starts_with("measure_broker") + - subtitle: "Hierarchy" + contents: - measure_hierarchy - subtitle: "Cohesion" contents: + - measure_breadth - measure_cohesion - starts_with("measure_closure") + - subtitle: "Topology" + contents: - measure_features - measure_fragmentation + - measure_core + - subtitle: "Heterogeneity" + contents: - starts_with("measure_assort") - starts_with("measure_diverse") - - measure_breadth - - measure_core - subtitle: "Dynamics" contents: - measure_periods diff --git a/pkgdown/favicon/apple-touch-icon.png b/pkgdown/favicon/apple-touch-icon.png index 3f1f39a6571d79b8995df6f894e41d99a0dc49c3..3da85740a794a5b5198cfefdc49b16960bac23cc 100644 GIT binary patch literal 9445 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 JPDHLkV1kz>pUwaP delta 2882 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 literal 15086 zcmdU0Yiv}<6~2ZbDpeJTA5}$*OjWg1YLW7zjf4Q1<_C!W08}X;dDn(IK&YTnRfKHs z8rGo%k|u$)Ng#Odu5D~$V-f;^n0LJfOaO;RdBwcU0>+Mc81t}M+iOqHne~~>y}q-1 zy$>fHX*{np=ljmwy>n;Ij5RGy8?L?hq6X=Bt@4+e_L8P)i%wj);!|8 z_Ty!&udk0i?R?DgeOba5caihwspc_Q{hs3aKgrb{y~O?DlY8*4t^|cKh~icJt;M_I4g+{@9Mct2aJgD(r930v2bsgC>zqN zSFf_BrY0UQUc6{%qr$@{t-=P-8TM>h^5bN1w9=8gdGRQ~=pydT4{s3-%QV{fn>&1Y z`Jk{PUw2%w!cU|Q=8x0FyXM?3GrVOv7z~PfIWH}e`WEXSyxXexkMG(o<4ob-*;@j`yeQ6 z04|-&w>W`5g zd)WT3s#xv$JuLXm6{7-E>HhuuEF6x^3y05s_N^UWM{P74^1PYnkoy-Wx0*CYFJd3W zxfJ}0eYATuD?7A-V;(xQ1Ln$J`2ITCTigD|mEiop;nUpc3Zb>9r-!w*weh(Yg+JJ} zlI&2fr-*+{F{0H*md%?`l6D+gVp^kdL?!`NQ`iQT~-RA1e93zq7!=4ZpW?c<|tXTpW-`Zn<8^ ze^lcSb8A4wf8Dxu1Aaea#*E<@^7wakhm`yuJT|^3*m>5hS$v;!_Uu`LdNx+ z%s&takQh6D`2ah1VK3{2HAFH(EdDV5)rs)OceA%QIb5ymwZVUPy!^YnyBV$CWxZ#=pG0 zT+E+y=gwuzmM!CS<@tyById|I>vp@TI-wM~Mf-li{8^#mzj5P6!H3olW5;bZK=FCZmJ9du6AN(ZcD*nXToT6Uy_?u;@jIxOY)i6a=dU{s!gyE) z@C%o!_@}3*5BN=h5;L_hxN(D1 zGV{mv1==P@^O_?bTaX)!v2yc+K0s>1f#kBB#>rO44vfb%N;E}$)+ zA1_T6J*D2uw@%BvTRa}mFgv_unX0N+h6DeH^gNpGH!AUx)zI11!Q8OdC(OLktY68T z;>oY{FZey_TV3`V_X#G8udL_apSc8YI1BuHJY1vo$L6e?!=w(uK-%Fe~1@aAx*; z*>x@{PUIT#bj`0EbM_f5f;Ls^hg>U8A%Ia!Hf0%F|27Bm~Yp#QJ4la4ZZ2C z5bhU2+J|I3nij$|UDJY?j_OZG{{N<6F1G{B=XQ~uWH$@;j{}VhjT4RykAdNll*X0D zxzB_3cu#9Q#Ay&yaZfAHpY=z42k?#!Knz)c_1+8g?uELai@9$o2mo#fv76xxTMpu$ zgwNTK3LdcksYKjuflUGy@1fxR2l)=oJKLA@@yPdO|0NkbJf%6mL)?Pm1p=2cc#j14 zS5|jmun)!+dd`pHK2LZnt|Aueg^AEPF^i^f|uC6X|wu?CN;>!S!JKA{B?Vm4K$IRQo$GZi17A;3S zYfnb7gHO+l{6$q&RR(B$-l&J-VD9nQ#UC+Z1U@GbWdejNr*b>xH*NZMzM?w2_%b%; zlbdGVlY=Rd?yDJ62&hnDiUyog@qz-Hse6tJkoJESUn zum}BDydCEp^ry>_I9aH*RfXSa)L)=GCIFT)ky* ziw3%Xk9RNQ3E#Bc;r4663we*Y$`n z{KfvNVAbC&E905lQqf \ No newline at end of file +RealFaviconGeneratorhttps://realfavicongenerator.net \ No newline at end of file diff --git a/pkgdown/favicon/web-app-manifest-192x192.png b/pkgdown/favicon/web-app-manifest-192x192.png index d23f1c7cc7dfc3381b63edd27bb21fa56b0064d9..bd5db0c43f125acc0b677a78edfa9c57f631e72a 100644 GIT binary patch literal 9968 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 literal 7497 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%0Ar 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 literal 23364 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 From 788740336ef2526d9eea7f580a1f9f59d25497e4 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 10:06:54 +0200 Subject: [PATCH 09/68] Added `sim_rolesim()` and `sim_rege()`, recursive role similarity methods --- NEWS.md | 5 ++ R/method_equivalence.R | 177 ++++++++++++++++++++++++++++++++++++++ man/method_equivalence.Rd | 98 +++++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 R/method_equivalence.R create mode 100644 man/method_equivalence.Rd diff --git a/NEWS.md b/NEWS.md index ff3e588..bee66e5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -36,6 +36,11 @@ or its spread across layers in a multiplex network - Note that on weighted networks this counts ties where `net_by_heterophily()` sums weights, so the two agree only when unweighted +## Methods + +- Added `sim_rolesim()` and `sim_rege()`, recursive role similarity methods + - Note `sim_rege()` degenerate on unweighted connected ones, where it warns + # netrics 0.4.1 ## Package diff --git a/R/method_equivalence.R b/R/method_equivalence.R new file mode 100644 index 0000000..982bca8 --- /dev/null +++ b/R/method_equivalence.R @@ -0,0 +1,177 @@ +# Recursive role similarity #### + +#' Methods for calculating regular equivalence +#' @name method_equivalence +#' @description +#' These functions calculate how regularly equivalent each pair of nodes is, +#' returning a similarity matrix that [node_in_regular()] then clusters. +#' +#' - `sim_rolesim()` calculates RoleSim similarity. +#' - `sim_rege()` calculates REGE similarity. +#' +#' Both are recursive: two nodes are similar to the extent that their alters +#' are similar, which is the defining property of regular equivalence. +#' They differ in how they pair up two nodes' alters. +#' @template param_data +#' @param beta A decay parameter between 0 and 1 controlling how much weight +#' is given to the recursive component. By default 0.15. +#' @param iterations Integer number of iterations. +#' By default 3 for `sim_rege()`; `sim_rolesim()` iterates to convergence. +#' @returns A square similarity matrix with one row and column per node. +#' @references +#' ## On RoleSim +#' Jin, Ruoming, Victor E. Lee, and Hui Hong. 2011. +#' "Axiomatic ranking of network role similarity". +#' _Proceedings of the 17th ACM SIGKDD International Conference on Knowledge +#' Discovery and Data Mining_: 922-930. +#' \doi{10.1145/2020408.2020561} +#' +#' ## On REGE +#' White, Douglas R., and Karl P. Reitz. 1983. +#' "Graph and semigroup homomorphisms on networks of relations". +#' _Social Networks_ 5(2): 193-234. +#' \doi{10.1016/0378-8733(83)90025-4} +#' @family methods +NULL + +#' @rdname method_equivalence +#' @section RoleSim: +#' RoleSim pairs up two nodes' alters by finding the _maximal matching_ +#' between them, that is, the one-to-one pairing that maximises total +#' similarity, and then averages over it: +#' \deqn{s(u,v) = (1-\beta) \frac{\sum_{(x,y) \in M} s(x,y)}{|N(u)| + |N(v)| - |M|} + \beta} +#' where \eqn{M} is that matching. +#' Because each alter can be used only once, two nodes are similar only if +#' their neighbourhoods can be lined up as wholes. +#' +#' RoleSim satisfies the automorphic confirmation property, meaning that +#' automorphically equivalent nodes always score 1, and it is a metric. +#' It converges to a unique solution regardless of where it starts, +#' so the result does not depend on initialisation. +#' @export +sim_rolesim <- function(.data, beta = 0.15){ + .data <- manynet::expect_nodes(.data) + if(beta < 0 | beta > 1) + manynet::snet_abort("`beta` must be a proportion between 0 and 1.") + mat <- manynet::as_matrix(manynet::to_unweighted(manynet::to_multilevel(.data))) + n <- nrow(mat) + nbrs <- .neighbourhoods(mat, manynet::is_directed(.data)) + sim <- matrix(1, n, n) # all nodes begin maximally similar + for(it in seq_len(100L)){ + new <- .rolesim_step(sim, nbrs, beta, n) + if(max(abs(new - sim)) < 1e-6){ sim <- new; break } + sim <- new + } + dimnames(sim) <- list(rownames(mat), rownames(mat)) + sim +} + +.rolesim_step <- function(sim, nbrs, beta, n){ + new <- diag(n) + for(u in seq_len(n)) for(v in seq_len(u)){ + # average the matchings over each direction of tie, so that in a directed + # network nodes must match on both whom they reach and who reaches them + scores <- vapply(nbrs, function(nb){ + nu <- nb[[u]]; nv <- nb[[v]] + if(length(nu) == 0 && length(nv) == 0) return(1) + if(length(nu) == 0 || length(nv) == 0) return(0) + matched <- .greedy_matching(sim[nu, nv, drop = FALSE]) + matched/(length(nu) + length(nv) - min(length(nu), length(nv))) + }, FUN.VALUE = numeric(1)) + new[u, v] <- new[v, u] <- (1-beta)*mean(scores) + beta + } + diag(new) <- 1 + new +} + +# Greedily approximate the maximal matching between two neighbourhoods, +# repeatedly taking the most similar remaining pair. The RoleSim authors show +# this is a bounded approximation of the optimal (Hungarian) matching at a +# fraction of the cost. +.greedy_matching <- function(sub){ + total <- 0 + while(nrow(sub) > 0 && ncol(sub) > 0){ + best <- which.max(sub) + i <- ((best - 1) %% nrow(sub)) + 1 + j <- ((best - 1) %/% nrow(sub)) + 1 + total <- total + sub[i, j] + sub <- sub[-i, -j, drop = FALSE] + } + total +} + +#' @rdname method_equivalence +#' @section REGE: +#' REGE instead pairs each alter with its _best_ counterpart, allowing the +#' same alter to be used more than once: +#' \deqn{s(u,v) = \frac{\sum_{x \in N(u)} \max_{y \in N(v)} s(x,y) + \sum_{y \in N(v)} \max_{x \in N(u)} s(x,y)}{|N(u)| + |N(v)|}} +#' +#' Matching with replacement makes REGE more permissive than RoleSim: a node +#' with many alters can be judged similar to one with few, if those few +#' resemble all of the many. Which behaviour is wanted depends on whether +#' having more alters of a kind is itself part of the role. +#' +#' REGE is the algorithm UCINET implements, so use it when comparing results +#' against that software. Unlike RoleSim it has no convergence guarantee and +#' is sensitive to the number of iterations, so this is fixed rather than run +#' to convergence. +#' +#' Note that REGE is defined for _valued_ networks, and weights each matched +#' pair by how similar the two ties' strengths are. +#' On an unweighted, connected network it is degenerate: since every node has +#' an alter that matches every other node's alter perfectly, all nodes come +#' out maximally equivalent, which is the correct but uninformative answer +#' that the maximal regular equivalence of a connected graph is a single +#' class. Use `sim_rolesim()` for unweighted networks. +#' @export +sim_rege <- function(.data, iterations = 3){ + .data <- manynet::expect_nodes(.data) + mat <- manynet::as_matrix(manynet::to_multilevel(.data)) + if(!manynet::is_weighted(.data) && manynet::is_connected(.data)) + manynet::snet_warn("REGE is degenerate on unweighted connected networks,", + "where all nodes are maximally regularly equivalent.", + "Consider {.fn sim_rolesim} instead.") + n <- nrow(mat) + nbrs <- .neighbourhoods(mat, manynet::is_directed(.data)) + sim <- matrix(1, n, n) # all nodes begin maximally similar + for(it in seq_len(iterations)){ + new <- diag(n) + for(u in seq_len(n)) for(v in seq_len(u)){ + # weight each matched pair by how well the two ties' strengths agree, + # so that equivalence depends on the intensity of ties as well as + # their existence + agree <- outer(nbrs[[1]][[u]], nbrs[[1]][[v]], function(a, b) + pmin(mat[u, a], mat[v, b]) + pmin(mat[a, u], mat[b, v])) + nu <- nbrs[[1]][[u]]; nv <- nbrs[[1]][[v]] + if(length(nu) == 0 && length(nv) == 0){ + new[u, v] <- new[v, u] <- 1 + } else if(length(nu) == 0 || length(nv) == 0){ + new[u, v] <- new[v, u] <- 0 + } else { + wsim <- agree * sim[nu, nv, drop = FALSE] + # each alter takes its best counterpart, with replacement + num <- sum(apply(wsim, 1, max)) + sum(apply(wsim, 2, max)) + # normalise by the total tie strength each node has to give, so that + # a pair scores 1 only if all of it can be matched at equal strength + den <- sum(mat[u, nu]) + sum(mat[nu, u]) + + sum(mat[v, nv]) + sum(mat[nv, v]) + new[u, v] <- new[v, u] <- if(den == 0) 0 else num/den + } + } + diag(new) <- 1 + sim <- new + } + dimnames(sim) <- list(rownames(mat), rownames(mat)) + sim +} + +# A list of neighbourhood sets to match on. Undirected networks have one, +# directed networks two, so that nodes must match on both their outgoing and +# their incoming ties to count as regularly equivalent. +.neighbourhoods <- function(mat, directed){ + n <- nrow(mat) + outs <- lapply(seq_len(n), function(i) which(mat[i,] > 0)) + if(!directed) return(list(outs)) + ins <- lapply(seq_len(n), function(i) which(mat[,i] > 0)) + list(outs, ins) +} diff --git a/man/method_equivalence.Rd b/man/method_equivalence.Rd new file mode 100644 index 0000000..d533586 --- /dev/null +++ b/man/method_equivalence.Rd @@ -0,0 +1,98 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/method_equivalence.R +\name{method_equivalence} +\alias{method_equivalence} +\alias{sim_rolesim} +\alias{sim_rege} +\title{Methods for calculating regular equivalence} +\usage{ +sim_rolesim(.data, beta = 0.15) + +sim_rege(.data, iterations = 3) +} +\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{beta}{A decay parameter between 0 and 1 controlling how much weight +is given to the recursive component. By default 0.15.} + +\item{iterations}{Integer number of iterations. +By default 3 for \code{sim_rege()}; \code{sim_rolesim()} iterates to convergence.} +} +\value{ +A square similarity matrix with one row and column per node. +} +\description{ +These functions calculate how regularly equivalent each pair of nodes is, +returning a similarity matrix that \code{\link[=node_in_regular]{node_in_regular()}} then clusters. +\itemize{ +\item \code{sim_rolesim()} calculates RoleSim similarity. +\item \code{sim_rege()} calculates REGE similarity. +} + +Both are recursive: two nodes are similar to the extent that their alters +are similar, which is the defining property of regular equivalence. +They differ in how they pair up two nodes' alters. +} +\section{RoleSim}{ + +RoleSim pairs up two nodes' alters by finding the \emph{maximal matching} +between them, that is, the one-to-one pairing that maximises total +similarity, and then averages over it: +\deqn{s(u,v) = (1-\beta) \frac{\sum_{(x,y) \in M} s(x,y)}{|N(u)| + |N(v)| - |M|} + \beta} +where \eqn{M} is that matching. +Because each alter can be used only once, two nodes are similar only if +their neighbourhoods can be lined up as wholes. + +RoleSim satisfies the automorphic confirmation property, meaning that +automorphically equivalent nodes always score 1, and it is a metric. +It converges to a unique solution regardless of where it starts, +so the result does not depend on initialisation. +} + +\section{REGE}{ + +REGE instead pairs each alter with its \emph{best} counterpart, allowing the +same alter to be used more than once: +\deqn{s(u,v) = \frac{\sum_{x \in N(u)} \max_{y \in N(v)} s(x,y) + \sum_{y \in N(v)} \max_{x \in N(u)} s(x,y)}{|N(u)| + |N(v)|}} + +Matching with replacement makes REGE more permissive than RoleSim: a node +with many alters can be judged similar to one with few, if those few +resemble all of the many. Which behaviour is wanted depends on whether +having more alters of a kind is itself part of the role. + +REGE is the algorithm UCINET implements, so use it when comparing results +against that software. Unlike RoleSim it has no convergence guarantee and +is sensitive to the number of iterations, so this is fixed rather than run +to convergence. + +Note that REGE is defined for \emph{valued} networks, and weights each matched +pair by how similar the two ties' strengths are. +On an unweighted, connected network it is degenerate: since every node has +an alter that matches every other node's alter perfectly, all nodes come +out maximally equivalent, which is the correct but uninformative answer +that the maximal regular equivalence of a connected graph is a single +class. Use \code{sim_rolesim()} for unweighted networks. +} + +\references{ +\subsection{On RoleSim}{ + +Jin, Ruoming, Victor E. Lee, and Hui Hong. 2011. +"Axiomatic ranking of network role similarity". +\emph{Proceedings of the 17th ACM SIGKDD International Conference on Knowledge +Discovery and Data Mining}: 922-930. +\doi{10.1145/2020408.2020561} +} + +\subsection{On REGE}{ + +White, Douglas R., and Karl P. Reitz. 1983. +"Graph and semigroup homomorphisms on networks of relations". +\emph{Social Networks} 5(2): 193-234. +\doi{10.1016/0378-8733(83)90025-4} +} +} +\concept{methods} From 53e154e14a97094353e55056b1bbc88ed9771541 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 10:40:00 +0200 Subject: [PATCH 10/68] Added `net_by_compactness()` for the average closeness of all pairs of nodes --- .Rbuildignore | 1 + .gitignore | 1 + NAMESPACE | 1 + NEWS.md | 1 + R/measure_cohesion.R | 59 ++++++++++++++++++++++++-- man-roxygen/param_data.R | 6 +-- man/mark_core.Rd | 6 +-- man/mark_degree.Rd | 6 +-- man/mark_diff.Rd | 6 +-- man/mark_dyads.Rd | 6 +-- man/mark_nodes.Rd | 6 +-- man/mark_select_node.Rd | 6 +-- man/mark_select_tie.Rd | 6 +-- man/mark_ties.Rd | 6 +-- man/mark_triangles.Rd | 6 +-- man/measure_assort_net.Rd | 6 +-- man/measure_assort_node.Rd | 6 +-- man/measure_breadth.Rd | 6 +-- man/measure_broker_node.Rd | 6 +-- man/measure_broker_tie.Rd | 6 +-- man/measure_brokerage.Rd | 6 +-- man/measure_central_between.Rd | 6 +-- man/measure_central_degree.Rd | 6 +-- man/measure_central_eigen.Rd | 6 +-- man/measure_centralisation_between.Rd | 6 +-- man/measure_centralisation_degree.Rd | 6 +-- man/measure_centralisation_eigen.Rd | 6 +-- man/measure_centralities_between.Rd | 6 +-- man/measure_centralities_close.Rd | 6 +-- man/measure_centralities_degree.Rd | 6 +-- man/measure_centralities_eigen.Rd | 6 +-- man/measure_closure.Rd | 6 +-- man/measure_closure_node.Rd | 6 +-- man/measure_cohesion.Rd | 54 ++++++++++++++++++++--- man/measure_core.Rd | 6 +-- man/measure_diffusion_node.Rd | 6 +-- man/measure_diverse_net.Rd | 6 +-- man/measure_diverse_node.Rd | 6 +-- man/measure_fragmentation.Rd | 6 +-- man/measure_hierarchy.Rd | 6 +-- man/measure_periods.Rd | 6 +-- man/member_brokerage.Rd | 9 ++-- man/member_cliques.Rd | 9 ++-- man/member_community.Rd | 9 ++-- man/member_community_hier.Rd | 9 ++-- man/member_community_non.Rd | 9 ++-- man/member_components.Rd | 9 ++-- man/member_core.Rd | 9 ++-- man/member_diffusion.Rd | 9 ++-- man/method_cluster.Rd | 6 +-- man/method_kselect.Rd | 6 +-- man/motif_brokerage_net.Rd | 6 +-- man/motif_brokerage_node.Rd | 6 +-- man/motif_clique.Rd | 10 ++--- man/motif_composition.Rd | 6 +-- man/motif_exposure.Rd | 6 +-- man/motif_hazard.Rd | 6 +-- man/motif_hierarchy.Rd | 6 +-- man/motif_homophily.Rd | 6 +-- man/motif_net.Rd | 6 +-- man/motif_node.Rd | 6 +-- man/motif_path.Rd | 6 +-- man/motif_periods.Rd | 6 +-- tests/testthat/test-measure_cohesion.R | 34 ++++++++++++++- tests/testthat/test-motif_cliques.R | 4 +- 65 files changed, 324 insertions(+), 201 deletions(-) diff --git a/.Rbuildignore b/.Rbuildignore index b89f35b..08cc738 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -23,3 +23,4 @@ vignettes/precompile\.R ^\.positai$ ^\.claude$ ^CLAUDE\.md$ +^Rplots\.pdf$ diff --git a/.gitignore b/.gitignore index b36ebae..55a1638 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ docs/ working/* /doc/ /Meta/ +Rplots.pdf tests/testthat/Rplots.pdf .DS_Store CRAN-SUBMISSION diff --git a/NAMESPACE b/NAMESPACE index cbc837a..fd17c84 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -19,6 +19,7 @@ export(net_by_balance) export(net_by_betweenness) export(net_by_closeness) export(net_by_cohesion) +export(net_by_compactness) export(net_by_components) export(net_by_congruency) export(net_by_connectedness) diff --git a/NEWS.md b/NEWS.md index bee66e5..6d03a13 100644 --- a/NEWS.md +++ b/NEWS.md @@ -11,6 +11,7 @@ ## Measures - Added `net_by_cyclicality()` for detecting generalised exchange +- Added `net_by_compactness()` for the average closeness of all pairs of nodes ## Memberships diff --git a/R/measure_cohesion.R b/R/measure_cohesion.R index 216bd61..ffe51df 100644 --- a/R/measure_cohesion.R +++ b/R/measure_cohesion.R @@ -7,7 +7,9 @@ #' #' - `net_by_density()` measures the ratio of ties to the number #' of possible ties. -#' - `net_by_components()` measures the number of (strong) components +#' - `net_by_compactness()` measures the average closeness of all pairs +#' of nodes in the network. +#' - `net_by_components()` measures the number of (strong) components #' in the network. #' - `net_by_independence()` measures the independence number, #' or size of the largest independent set in the network. @@ -35,13 +37,62 @@ net_by_density <- function(.data) { } #' @rdname measure_cohesion -#' @section Components: +#' @section Compactness: +#' Compactness is the average of the reciprocal distances between all pairs +#' of nodes: +#' \deqn{C = \frac{\sum_{i \neq j} \frac{1}{d(i,j)}}{N(N-1)}} +#' where unreachable pairs contribute \eqn{0}. +#' Its complement, \eqn{1 - C}, is sometimes called breadth. +#' +#' Compactness is more discriminating than +#' [net_by_connectedness()], which counts only whether pairs are reachable at +#' all. Two networks in which every node can reach every other are equally +#' connected, but the one in which they do so in fewer steps is more compact. +#' A complete network scores 1, and an empty network 0. +#' It is the network-level counterpart of [node_by_harmonic()], such that +#' `net_by_compactness(ison_adolescents) == mean(node_by_harmonic(ison_adolescents, normalized = TRUE, cutoff = -1))`. +#' +#' Note that this quantity is known in the physics literature as the +#' _global efficiency_ of a network (Latora and Marchiori 2001). +#' It is named compactness here for the social network analytic tradition, +#' partly to avoid confusion with the unrelated +#' [net_by_efficiency()] (Krackhardt) and [node_by_efficiency()] (Burt). +#' @references +#' ## On compactness +#' Borgatti, Stephen P., Martin G. Everett, Jeffrey C. Johnson, +#' and Filip Agneessens. 2022. +#' _Analyzing Social Networks Using R_, chapter 10. +#' London: SAGE. +#' +#' Latora, Vito, and Massimo Marchiori. 2001. +#' "Efficient Behavior of Small-World Networks". +#' _Physical Review Letters_ 87(19): 198701. +#' \doi{10.1103/PhysRevLett.87.198701} +#' @examples +#' net_by_compactness(ison_adolescents) +#' net_by_compactness(ison_southern_women) +#' @export +net_by_compactness <- function(.data) { + .data <- manynet::expect_nodes(.data) + # note that igraph's default mode ignores direction, which would treat a + # directed network as though every tie ran both ways + dists <- igraph::distances(manynet::as_igraph(.data), mode = "out") + recip <- 1/dists + diag(recip) <- 0 # exclude self-pairs + recip[!is.finite(recip)] <- 0 # unreachable pairs contribute nothing + n <- manynet::net_nodes(.data) + out <- if(n < 2) NaN else sum(recip)/(n*(n-1)) + make_network_measure(out, .data, call = deparse(sys.call())) +} + +#' @rdname measure_cohesion +#' @section Components: #' To get the 'weak' components of a directed graph, #' please use `manynet::to_undirected()` first. #' @importFrom igraph components #' @examples -#' net_by_components(fict_thrones) -#' net_by_components(to_undirected(fict_thrones)) +#' net_by_components(fict_thrones) +#' net_by_components(to_undirected(fict_thrones)) #' @export net_by_components <- function(.data){ .data <- manynet::expect_nodes(.data) diff --git a/man-roxygen/param_data.R b/man-roxygen/param_data.R index 86fcf38..0d2dba1 100644 --- a/man-roxygen/param_data.R +++ b/man-roxygen/param_data.R @@ -1,3 +1,3 @@ -#' @param .data A network object of class `mnet`, `igraph`, `tbl_graph`, `network`, or similar. -#' For more information on the standard coercion possible, -#' see [manynet::as_tidygraph()]. +#' @param .data A network object of class `stocnet`, `igraph`, `tbl_graph`, `network`, or similar. +#' Internally any of these will be coerced to an efficient implementation. +#' For more information on possible coercions, see e.g. [manynet::as_stocnet()]. diff --git a/man/mark_core.Rd b/man/mark_core.Rd index 9600f00..85ea802 100644 --- a/man/mark_core.Rd +++ b/man/mark_core.Rd @@ -8,9 +8,9 @@ node_is_core(.data, centrality = c("degree", "eigenvector")) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{centrality}{Which centrality measure to use to identify cores and periphery. By default this is "degree", diff --git a/man/mark_degree.Rd b/man/mark_degree.Rd index e0d6d23..6f0f13c 100644 --- a/man/mark_degree.Rd +++ b/man/mark_degree.Rd @@ -14,9 +14,9 @@ node_is_pendant(.data) node_is_universal(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{node_mark} logical vector the length of the nodes in the network, diff --git a/man/mark_diff.Rd b/man/mark_diff.Rd index 668835f..0819207 100644 --- a/man/mark_diff.Rd +++ b/man/mark_diff.Rd @@ -17,9 +17,9 @@ node_is_recovered(.data, time = 0) node_is_exposed(.data, mark, time = 0) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{time}{A time step at which nodes are identified.} diff --git a/man/mark_dyads.Rd b/man/mark_dyads.Rd index d1d99e0..b997641 100644 --- a/man/mark_dyads.Rd +++ b/man/mark_dyads.Rd @@ -11,9 +11,9 @@ tie_is_multiple(.data) tie_is_reciprocated(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{tie_mark} logical vector the length of the ties in the network, diff --git a/man/mark_nodes.Rd b/man/mark_nodes.Rd index 8ad61a0..da64a22 100644 --- a/man/mark_nodes.Rd +++ b/man/mark_nodes.Rd @@ -20,9 +20,9 @@ node_is_mentor(.data, elites = 0.1) node_is_neighbor(.data, node) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{elites}{The proportion of nodes to be selected as mentors. By default this is set at 0.1. diff --git a/man/mark_select_node.Rd b/man/mark_select_node.Rd index 84d0941..e7ddefb 100644 --- a/man/mark_select_node.Rd +++ b/man/mark_select_node.Rd @@ -17,9 +17,9 @@ node_is_min(node_measure, ranks = 1) node_is_mean(node_measure, ranks = 1) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{select}{Number of elements to select (as TRUE).} diff --git a/man/mark_select_tie.Rd b/man/mark_select_tie.Rd index 7503b01..ae486b2 100644 --- a/man/mark_select_tie.Rd +++ b/man/mark_select_tie.Rd @@ -14,9 +14,9 @@ tie_is_max(tie_measure) tie_is_min(tie_measure) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{select}{Number of elements to select (as TRUE).} diff --git a/man/mark_ties.Rd b/man/mark_ties.Rd index 18f0d68..974f3c2 100644 --- a/man/mark_ties.Rd +++ b/man/mark_ties.Rd @@ -17,9 +17,9 @@ tie_is_bridge(.data) tie_is_path(.data, from, to, all_paths = FALSE) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{from}{The index or name of the node from which the path should start.} diff --git a/man/mark_triangles.Rd b/man/mark_triangles.Rd index 7a25ade..5ec173b 100644 --- a/man/mark_triangles.Rd +++ b/man/mark_triangles.Rd @@ -23,9 +23,9 @@ tie_is_simmelian(.data) tie_is_imbalanced(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{tie_mark} logical vector the length of the ties in the network, diff --git a/man/measure_assort_net.Rd b/man/measure_assort_net.Rd index fdc480b..47ffa9d 100644 --- a/man/measure_assort_net.Rd +++ b/man/measure_assort_net.Rd @@ -21,9 +21,9 @@ net_by_assortativity(.data) net_by_spatial(.data, attribute) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{attribute}{Name of a nodal attribute, mark, measure, or membership vector.} diff --git a/man/measure_assort_node.Rd b/man/measure_assort_node.Rd index eadf939..8eb2fd7 100644 --- a/man/measure_assort_node.Rd +++ b/man/measure_assort_node.Rd @@ -15,9 +15,9 @@ node_by_homophily( ) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{attribute}{Name of a nodal attribute, mark, measure, or membership vector.} diff --git a/man/measure_breadth.Rd b/man/measure_breadth.Rd index 3ec249a..0405d79 100644 --- a/man/measure_breadth.Rd +++ b/man/measure_breadth.Rd @@ -11,9 +11,9 @@ net_by_diameter(.data) net_by_length(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{network_measure} numeric score. diff --git a/man/measure_broker_node.Rd b/man/measure_broker_node.Rd index 122725c..78d1f96 100644 --- a/man/measure_broker_node.Rd +++ b/man/measure_broker_node.Rd @@ -26,9 +26,9 @@ node_by_hierarchy(.data) node_by_neighbours_degree(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{node_measure} numeric vector the length of the nodes in the network, diff --git a/man/measure_broker_tie.Rd b/man/measure_broker_tie.Rd index 7d0d1cc..4e8d591 100644 --- a/man/measure_broker_tie.Rd +++ b/man/measure_broker_tie.Rd @@ -8,9 +8,9 @@ tie_by_cohesion(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{tie_measure} numeric vector the length of the ties in the network, diff --git a/man/measure_brokerage.Rd b/man/measure_brokerage.Rd index 35fbebe..5083142 100644 --- a/man/measure_brokerage.Rd +++ b/man/measure_brokerage.Rd @@ -11,9 +11,9 @@ node_by_brokering_activity(.data, membership) node_by_brokering_exclusivity(.data, membership) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{membership}{A character string naming an existing node attribute in the network, or a categorical vector of the same length as the number of diff --git a/man/measure_central_between.Rd b/man/measure_central_between.Rd index 4e78457..a974828 100644 --- a/man/measure_central_between.Rd +++ b/man/measure_central_between.Rd @@ -17,9 +17,9 @@ node_by_flow(.data, normalized = TRUE) node_by_stress(.data, normalized = TRUE) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, diff --git a/man/measure_central_degree.Rd b/man/measure_central_degree.Rd index e10e71f..da75251 100644 --- a/man/measure_central_degree.Rd +++ b/man/measure_central_degree.Rd @@ -31,9 +31,9 @@ node_by_posneg(.data) node_by_leverage(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, diff --git a/man/measure_central_eigen.Rd b/man/measure_central_eigen.Rd index 12ecb34..e1c42f2 100644 --- a/man/measure_central_eigen.Rd +++ b/man/measure_central_eigen.Rd @@ -26,9 +26,9 @@ node_by_hub(.data) node_by_subgraph(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, diff --git a/man/measure_centralisation_between.Rd b/man/measure_centralisation_between.Rd index d9ed310..50f6494 100644 --- a/man/measure_centralisation_between.Rd +++ b/man/measure_centralisation_between.Rd @@ -15,9 +15,9 @@ mode_by_betweenness( ) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, diff --git a/man/measure_centralisation_degree.Rd b/man/measure_centralisation_degree.Rd index eb19b64..c5c81ff 100644 --- a/man/measure_centralisation_degree.Rd +++ b/man/measure_centralisation_degree.Rd @@ -23,9 +23,9 @@ mode_by_outdegree(.data, normalized = TRUE) mode_by_indegree(.data, normalized = TRUE) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, diff --git a/man/measure_centralisation_eigen.Rd b/man/measure_centralisation_eigen.Rd index d60afdc..77c5de8 100644 --- a/man/measure_centralisation_eigen.Rd +++ b/man/measure_centralisation_eigen.Rd @@ -11,9 +11,9 @@ net_by_eigenvector(.data, normalized = TRUE) mode_by_eigenvector(.data, normalized = TRUE) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, diff --git a/man/measure_centralities_between.Rd b/man/measure_centralities_between.Rd index 793d002..140b9ee 100644 --- a/man/measure_centralities_between.Rd +++ b/man/measure_centralities_between.Rd @@ -8,9 +8,9 @@ tie_by_betweenness(.data, normalized = TRUE) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, diff --git a/man/measure_centralities_close.Rd b/man/measure_centralities_close.Rd index 4b980dc..7c41115 100644 --- a/man/measure_centralities_close.Rd +++ b/man/measure_centralities_close.Rd @@ -8,9 +8,9 @@ tie_by_closeness(.data, normalized = TRUE) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, diff --git a/man/measure_centralities_degree.Rd b/man/measure_centralities_degree.Rd index 676f610..e5bab4b 100644 --- a/man/measure_centralities_degree.Rd +++ b/man/measure_centralities_degree.Rd @@ -8,9 +8,9 @@ tie_by_degree(.data, normalized = TRUE) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, diff --git a/man/measure_centralities_eigen.Rd b/man/measure_centralities_eigen.Rd index 9fc7703..043017f 100644 --- a/man/measure_centralities_eigen.Rd +++ b/man/measure_centralities_eigen.Rd @@ -8,9 +8,9 @@ tie_by_eigenvector(.data, normalized = TRUE) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, diff --git a/man/measure_closure.Rd b/man/measure_closure.Rd index de0b15d..a3b611a 100644 --- a/man/measure_closure.Rd +++ b/man/measure_closure.Rd @@ -20,9 +20,9 @@ net_by_equivalency(.data) net_by_congruency(.data, object2) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{method}{For reciprocity, either \code{default} or \code{ratio}. See \code{?igraph::reciprocity}} diff --git a/man/measure_closure_node.Rd b/man/measure_closure_node.Rd index 1974b15..94e5da1 100644 --- a/man/measure_closure_node.Rd +++ b/man/measure_closure_node.Rd @@ -14,9 +14,9 @@ node_by_transitivity(.data) node_by_equivalency(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{node_measure} numeric vector the length of the nodes in the network, diff --git a/man/measure_cohesion.Rd b/man/measure_cohesion.Rd index 11859ee..9e9a250 100644 --- a/man/measure_cohesion.Rd +++ b/man/measure_cohesion.Rd @@ -3,20 +3,23 @@ \name{measure_cohesion} \alias{measure_cohesion} \alias{net_by_density} +\alias{net_by_compactness} \alias{net_by_components} \alias{net_by_independence} \title{Measures of network cohesion} \usage{ net_by_density(.data) +net_by_compactness(.data) + net_by_components(.data) net_by_independence(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{network_measure} numeric score. @@ -26,12 +29,37 @@ These functions return values or vectors relating to how cohesive a network is: \itemize{ \item \code{net_by_density()} measures the ratio of ties to the number of possible ties. +\item \code{net_by_compactness()} measures the average closeness of all pairs +of nodes in the network. \item \code{net_by_components()} measures the number of (strong) components in the network. \item \code{net_by_independence()} measures the independence number, or size of the largest independent set in the network. } } +\section{Compactness}{ + +Compactness is the average of the reciprocal distances between all pairs +of nodes: +\deqn{C = \frac{\sum_{i \neq j} \frac{1}{d(i,j)}}{N(N-1)}} +where unreachable pairs contribute \eqn{0}. +Its complement, \eqn{1 - C}, is sometimes called breadth. + +Compactness is more discriminating than +\code{\link[=net_by_connectedness]{net_by_connectedness()}}, which counts only whether pairs are reachable at +all. Two networks in which every node can reach every other are equally +connected, but the one in which they do so in fewer steps is more compact. +A complete network scores 1, and an empty network 0. +It is the network-level counterpart of \code{\link[=node_by_harmonic]{node_by_harmonic()}}, such that +\code{net_by_compactness(ison_adolescents) == mean(node_by_harmonic(ison_adolescents, normalized = TRUE, cutoff = -1))}. + +Note that this quantity is known in the physics literature as the +\emph{global efficiency} of a network (Latora and Marchiori 2001). +It is named compactness here for the social network analytic tradition, +partly to avoid confusion with the unrelated +\code{\link[=net_by_efficiency]{net_by_efficiency()}} (Krackhardt) and \code{\link[=node_by_efficiency]{node_by_efficiency()}} (Burt). +} + \section{Components}{ To get the 'weak' components of a directed graph, @@ -41,10 +69,26 @@ please use \code{manynet::to_undirected()} first. \examples{ net_by_density(ison_adolescents) net_by_density(ison_southern_women) - net_by_components(fict_thrones) - net_by_components(to_undirected(fict_thrones)) +net_by_compactness(ison_adolescents) +net_by_compactness(ison_southern_women) +net_by_components(fict_thrones) +net_by_components(to_undirected(fict_thrones)) net_by_independence(ison_adolescents) } +\references{ +\subsection{On compactness}{ + +Borgatti, Stephen P., Martin G. Everett, Jeffrey C. Johnson, +and Filip Agneessens. 2022. +\emph{Analyzing Social Networks Using R}, chapter 10. +London: SAGE. + +Latora, Vito, and Massimo Marchiori. 2001. +"Efficient Behavior of Small-World Networks". +\emph{Physical Review Letters} 87(19): 198701. +\doi{10.1103/PhysRevLett.87.198701} +} +} \seealso{ Other cohesion: \code{\link{mark_triangles}}, diff --git a/man/measure_core.Rd b/man/measure_core.Rd index 3d7d6c5..e70c978 100644 --- a/man/measure_core.Rd +++ b/man/measure_core.Rd @@ -11,9 +11,9 @@ node_by_kcoreness(.data) node_by_coreness(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{node_measure} numeric vector the length of the nodes in the network, diff --git a/man/measure_diffusion_node.Rd b/man/measure_diffusion_node.Rd index 219b504..8882fac 100644 --- a/man/measure_diffusion_node.Rd +++ b/man/measure_diffusion_node.Rd @@ -17,9 +17,9 @@ node_by_adopt_recovery(.data) node_by_adopt_exposure(.data, mark, time = 0) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, diff --git a/man/measure_diverse_net.Rd b/man/measure_diverse_net.Rd index 2947fc2..c6c11e6 100644 --- a/man/measure_diverse_net.Rd +++ b/man/measure_diverse_net.Rd @@ -15,9 +15,9 @@ net_by_diversity( ) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{attribute}{Name of a nodal attribute, mark, measure, or membership vector.} diff --git a/man/measure_diverse_node.Rd b/man/measure_diverse_node.Rd index d012f7c..81ef495 100644 --- a/man/measure_diverse_node.Rd +++ b/man/measure_diverse_node.Rd @@ -15,9 +15,9 @@ node_by_diversity( ) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{attribute}{Name of a nodal attribute, mark, measure, or membership vector.} diff --git a/man/measure_fragmentation.Rd b/man/measure_fragmentation.Rd index 35f65bb..b551501 100644 --- a/man/measure_fragmentation.Rd +++ b/man/measure_fragmentation.Rd @@ -17,9 +17,9 @@ net_by_strength(.data) net_by_toughness(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{network_measure} numeric score. diff --git a/man/measure_hierarchy.Rd b/man/measure_hierarchy.Rd index 9657007..1b93f2d 100644 --- a/man/measure_hierarchy.Rd +++ b/man/measure_hierarchy.Rd @@ -14,9 +14,9 @@ net_by_efficiency(.data) net_by_upperbound(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{network_measure} numeric score. diff --git a/man/measure_periods.Rd b/man/measure_periods.Rd index 19294b3..56deb70 100644 --- a/man/measure_periods.Rd +++ b/man/measure_periods.Rd @@ -8,9 +8,9 @@ net_by_waves(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{network_measure} numeric score. diff --git a/man/member_brokerage.Rd b/man/member_brokerage.Rd index 23c7684..a7dde2d 100644 --- a/man/member_brokerage.Rd +++ b/man/member_brokerage.Rd @@ -8,9 +8,9 @@ node_in_brokering(.data, membership) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{membership}{A character string naming an existing node attribute in the network, or a categorical vector of the same length as the number of @@ -55,8 +55,7 @@ Other memberships: \code{\link{member_components}}, \code{\link{member_core}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}}, -\code{\link{method_equivalence}} +\code{\link{member_equivalence}} Other nodal: \code{\link{mark_core}}, diff --git a/man/member_cliques.Rd b/man/member_cliques.Rd index b6b46d7..cc15a68 100644 --- a/man/member_cliques.Rd +++ b/man/member_cliques.Rd @@ -8,9 +8,9 @@ node_in_roulette(.data, num_groups, group_size, times = NULL) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{num_groups}{An integer indicating the number of groups desired.} @@ -87,8 +87,7 @@ Other memberships: \code{\link{member_components}}, \code{\link{member_core}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}}, -\code{\link{method_equivalence}} +\code{\link{member_equivalence}} Other nodal: \code{\link{mark_core}}, diff --git a/man/member_community.Rd b/man/member_community.Rd index 1c1fd18..3480866 100644 --- a/man/member_community.Rd +++ b/man/member_community.Rd @@ -8,9 +8,9 @@ node_in_community(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{node_member} character vector the length of the nodes in the network, @@ -42,8 +42,7 @@ Other memberships: \code{\link{member_components}}, \code{\link{member_core}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}}, -\code{\link{method_equivalence}} +\code{\link{member_equivalence}} Other nodal: \code{\link{mark_core}}, diff --git a/man/member_community_hier.Rd b/man/member_community_hier.Rd index f8d1360..717df2a 100644 --- a/man/member_community_hier.Rd +++ b/man/member_community_hier.Rd @@ -17,9 +17,9 @@ node_in_eigen(.data) node_in_walktrap(.data, times = 50) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{times}{Integer indicating number of simulations/walks used. By default, \code{times=50}.} @@ -137,8 +137,7 @@ Other memberships: \code{\link{member_components}}, \code{\link{member_core}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}}, -\code{\link{method_equivalence}} +\code{\link{member_equivalence}} Other nodal: \code{\link{mark_core}}, diff --git a/man/member_community_non.Rd b/man/member_community_non.Rd index 40d0b78..25cc419 100644 --- a/man/member_community_non.Rd +++ b/man/member_community_non.Rd @@ -29,9 +29,9 @@ node_in_leiden(.data, resolution = 1) node_in_labels(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{times}{Integer indicating number of simulations/walks used. By default, \code{times=50}.} @@ -271,8 +271,7 @@ Other memberships: \code{\link{member_components}}, \code{\link{member_core}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}}, -\code{\link{method_equivalence}} +\code{\link{member_equivalence}} Other nodal: \code{\link{mark_core}}, diff --git a/man/member_components.Rd b/man/member_components.Rd index 8cc214d..cd2df80 100644 --- a/man/member_components.Rd +++ b/man/member_components.Rd @@ -14,9 +14,9 @@ node_in_weak(.data) node_in_strong(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{node_member} character vector the length of the nodes in the network, @@ -59,8 +59,7 @@ Other memberships: \code{\link{member_community_non}}, \code{\link{member_core}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}}, -\code{\link{method_equivalence}} +\code{\link{member_equivalence}} Other nodal: \code{\link{mark_core}}, diff --git a/man/member_core.Rd b/man/member_core.Rd index 54e1aa5..c7644a6 100644 --- a/man/member_core.Rd +++ b/man/member_core.Rd @@ -8,9 +8,9 @@ node_in_core(.data, groups = 3, cluster_by = c("bins", "quantiles", "kmeans")) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{groups}{Number of categories to create. Must be at least 2 and at most the number of nodes in the network. Default is 3.} @@ -64,8 +64,7 @@ Other memberships: \code{\link{member_community_non}}, \code{\link{member_components}}, \code{\link{member_diffusion}}, -\code{\link{member_equivalence}}, -\code{\link{method_equivalence}} +\code{\link{member_equivalence}} Other nodal: \code{\link{mark_core}}, diff --git a/man/member_diffusion.Rd b/man/member_diffusion.Rd index 06a7969..2ffca1b 100644 --- a/man/member_diffusion.Rd +++ b/man/member_diffusion.Rd @@ -8,9 +8,9 @@ node_in_adopter(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{node_member} character vector the length of the nodes in the network, @@ -61,8 +61,7 @@ Other memberships: \code{\link{member_community_non}}, \code{\link{member_components}}, \code{\link{member_core}}, -\code{\link{member_equivalence}}, -\code{\link{method_equivalence}} +\code{\link{member_equivalence}} Other nodal: \code{\link{mark_core}}, diff --git a/man/method_cluster.Rd b/man/method_cluster.Rd index 7543fd3..3e15e39 100644 --- a/man/method_cluster.Rd +++ b/man/method_cluster.Rd @@ -22,9 +22,9 @@ By default \code{"euclidean"}, but other options include \code{"maximum"}, \code{"manhattan"}, \code{"canberra"}, \code{"binary"}, and \code{"minkowski"}. Fewer, identifiable letters, e.g. \code{"e"} for Euclidean, is sufficient.} -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A hierarchical clustering object created by \code{stats::hclust()}, diff --git a/man/method_kselect.Rd b/man/method_kselect.Rd index 803436a..7fbb90a 100644 --- a/man/method_kselect.Rd +++ b/man/method_kselect.Rd @@ -19,9 +19,9 @@ k_gap(hc, motif, Kmax, sims = 100) \arguments{ \item{hc}{A hierarchical clustering object.} -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{motif}{A motif census object.} diff --git a/man/motif_brokerage_net.Rd b/man/motif_brokerage_net.Rd index fae7222..6466fc1 100644 --- a/man/motif_brokerage_net.Rd +++ b/man/motif_brokerage_net.Rd @@ -8,9 +8,9 @@ net_x_brokerage(.data, membership, standardized = FALSE) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{membership}{A character string naming an existing node attribute in the network, or a categorical vector of the same length as the number of diff --git a/man/motif_brokerage_node.Rd b/man/motif_brokerage_node.Rd index 7f76302..fe2ee84 100644 --- a/man/motif_brokerage_node.Rd +++ b/man/motif_brokerage_node.Rd @@ -8,9 +8,9 @@ node_x_brokerage(.data, membership, standardized = FALSE) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{membership}{A character string naming an existing node attribute in the network, or a categorical vector of the same length as the number of diff --git a/man/motif_clique.Rd b/man/motif_clique.Rd index 5af6814..b451fce 100644 --- a/man/motif_clique.Rd +++ b/man/motif_clique.Rd @@ -5,12 +5,12 @@ \alias{node_x_clique} \title{Motifs of clique participation} \usage{ -node_x_clique(.data, min = 3) +node_x_clique(.data, min_clique_size = 3) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{min_clique_size}{Integer, the minimum size of clique to return. By default 3, since dyads and isolates are trivially cliques. @@ -57,7 +57,7 @@ considered. Use \code{\link[manynet:to_unsigned]{manynet::to_unsigned()}} first \examples{ node_x_clique(ison_adolescents) -node_x_clique(ison_southern_women, min = c(3, 3)) +node_x_clique(ison_southern_women, min_clique_size = c(3, 3)) } \references{ \subsection{On cliques}{ diff --git a/man/motif_composition.Rd b/man/motif_composition.Rd index eecbe4e..585bcb4 100644 --- a/man/motif_composition.Rd +++ b/man/motif_composition.Rd @@ -14,9 +14,9 @@ node_x_alters(.data, attribute) node_x_similarity(.data, attribute) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{direction}{Character string, “out” bases the measure on outgoing ties, “in” on incoming ties, and "all" on either/the sum of the two. diff --git a/man/motif_exposure.Rd b/man/motif_exposure.Rd index a836fe2..3a9a462 100644 --- a/man/motif_exposure.Rd +++ b/man/motif_exposure.Rd @@ -8,9 +8,9 @@ node_x_exposure(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{node_motif} matrix with one row for each node in the network and diff --git a/man/motif_hazard.Rd b/man/motif_hazard.Rd index cc5f6c6..2cd299d 100644 --- a/man/motif_hazard.Rd +++ b/man/motif_hazard.Rd @@ -11,9 +11,9 @@ net_x_hazard(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{network_motif} named numeric vector or sometimes a data frame with diff --git a/man/motif_hierarchy.Rd b/man/motif_hierarchy.Rd index 63c1300..34d8581 100644 --- a/man/motif_hierarchy.Rd +++ b/man/motif_hierarchy.Rd @@ -8,9 +8,9 @@ net_x_hierarchy(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{network_motif} named numeric vector or sometimes a data frame with diff --git a/man/motif_homophily.Rd b/man/motif_homophily.Rd index 6b79bc2..95e8b49 100644 --- a/man/motif_homophily.Rd +++ b/man/motif_homophily.Rd @@ -8,9 +8,9 @@ net_x_homophily(.data, attribute) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{attribute}{Name of a nodal attribute, mark, measure, or membership vector.} } diff --git a/man/motif_net.Rd b/man/motif_net.Rd index 46c6053..810597c 100644 --- a/man/motif_net.Rd +++ b/man/motif_net.Rd @@ -20,9 +20,9 @@ net_x_tetrad(.data) net_x_mixed(.data, object2) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{object2}{A second, two-mode network object.} } diff --git a/man/motif_node.Rd b/man/motif_node.Rd index b780ad5..ef74feb 100644 --- a/man/motif_node.Rd +++ b/man/motif_node.Rd @@ -14,9 +14,9 @@ node_x_triad(.data) node_x_tetrad(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{node_motif} matrix with one row for each node in the network and diff --git a/man/motif_path.Rd b/man/motif_path.Rd index 6c56c5e..88928b5 100644 --- a/man/motif_path.Rd +++ b/man/motif_path.Rd @@ -11,9 +11,9 @@ node_x_tie(.data) node_x_path(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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()}}.} } \value{ A \code{node_motif} matrix with one row for each node in the network and diff --git a/man/motif_periods.Rd b/man/motif_periods.Rd index 7ae8362..21e9523 100644 --- a/man/motif_periods.Rd +++ b/man/motif_periods.Rd @@ -14,9 +14,9 @@ net_x_stability(.data, object2) net_x_correlation(.data, object2) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{object2}{A network object.} } diff --git a/tests/testthat/test-measure_cohesion.R b/tests/testthat/test-measure_cohesion.R index ddbf321..dfdffb6 100644 --- a/tests/testthat/test-measure_cohesion.R +++ b/tests/testthat/test-measure_cohesion.R @@ -29,4 +29,36 @@ test_that("net_strength works", { test_that("net_toughness works", { expect_values(net_by_toughness(ison_adolescents), 0.5) -}) \ No newline at end of file +}) +test_that("network compactness works", { + expect_equal(as.numeric(net_by_compactness(create_filled(10))), 1) + expect_equal(as.numeric(net_by_compactness(create_empty(10))), 0) + # compactness discriminates where connectedness cannot: + # both are fully connected, but the star is more compact than the ring + expect_gt(as.numeric(net_by_compactness(create_star(10))), + as.numeric(net_by_compactness(create_ring(10)))) + expect_equal(as.numeric(net_by_connectedness(create_star(10))), + as.numeric(net_by_connectedness(create_ring(10)))) + expect_values(net_by_compactness(ison_adolescents), 0.616) + expect_values(net_by_compactness(ison_southern_women), 0.515) + # compactness is the network-level counterpart of harmonic centrality, + # and is the quantity known elsewhere as global efficiency + expect_equal(as.numeric(net_by_compactness(ison_adolescents)), + mean(as.numeric(node_by_harmonic(ison_adolescents, + normalized = TRUE, + cutoff = -1)))) +}) + +test_that("net_by_compactness respects tie direction", { + # igraph's default distance mode ignores direction, which would treat a + # directed network as though every tie ran both ways + dir <- to_unweighted(ison_networkers) + expect_false(isTRUE(all.equal( + as.numeric(net_by_compactness(dir)), + as.numeric(net_by_compactness(to_undirected(dir)))))) + # a one-way chain is less compact than the same chain reciprocated + chain <- matrix(0, 4, 4) + chain[cbind(1:3, 2:4)] <- 1 + expect_lt(as.numeric(net_by_compactness(chain)), + as.numeric(net_by_compactness(chain + t(chain)))) +}) diff --git a/tests/testthat/test-motif_cliques.R b/tests/testthat/test-motif_cliques.R index b2a099f..75a9ee0 100644 --- a/tests/testthat/test-motif_cliques.R +++ b/tests/testthat/test-motif_cliques.R @@ -8,7 +8,7 @@ test_that("node_x_clique finds the maximal cliques", { min = 3))) # every returned clique respects the minimum size expect_true(all(colSums(res) >= 3)) - expect_true(all(colSums(node_x_clique(ison_adolescents, min = 4)) >= 4)) + expect_true(all(colSums(node_x_clique(ison_adolescents, min_clique_size = 4)) >= 4)) # and every returned clique really is complete mat <- manynet::as_matrix(ison_adolescents) for (j in seq_len(ncol(res))) { @@ -20,7 +20,7 @@ test_that("node_x_clique finds the maximal cliques", { }) test_that("node_x_clique finds bicliques in two-mode networks", { - res <- node_x_clique(ison_southern_women, min = c(3, 3)) + res <- node_x_clique(ison_southern_women, min_clique_size = c(3, 3)) expect_s3_class(res, "node_motif") expect_equal(nrow(res), c(manynet::net_nodes(ison_southern_women))) modes <- manynet::node_is_mode(ison_southern_women) From f65286c76c81c2e2c6c5429054044e5dad6a0dbe Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 11:30:51 +0200 Subject: [PATCH 11/68] Fixed `node_in_regular()` to compute regular equivalence --- NAMESPACE | 3 + NEWS.md | 10 ++- R/member_equivalence.R | 85 ++++++++++++++++--- ...thod_equivalence.R => method_regularity.R} | 22 ++--- R/motif_cliques.R | 14 +-- ...od_equivalence.Rd => method_regularity.Rd} | 24 +++--- tests/testthat/test-member_equivalence.R | 47 ++++++++++ 7 files changed, 162 insertions(+), 43 deletions(-) rename R/{method_equivalence.R => method_regularity.R} (92%) rename man/{method_equivalence.Rd => method_regularity.Rd} (85%) diff --git a/NAMESPACE b/NAMESPACE index fd17c84..2c63e23 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -134,6 +134,7 @@ export(node_in_infomap) export(node_in_labels) export(node_in_leiden) export(node_in_louvain) +export(node_in_motif) export(node_in_optimal) export(node_in_partition) export(node_in_regular) @@ -171,6 +172,8 @@ export(node_x_tetrad) export(node_x_tie) export(node_x_ties) export(node_x_triad) +export(regularity_rege) +export(regularity_rolesim) export(tie_by_betweenness) export(tie_by_closeness) export(tie_by_cohesion) diff --git a/NEWS.md b/NEWS.md index 6d03a13..0fb592e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -16,6 +16,12 @@ ## Memberships - Added `node_in_labels()` for label propagation community detection +- Fixed `node_in_regular()` to compute regular equivalence using recursive + similarity (`regularity = "rolesim"` (default) or `"rege"`) between nodes + rather than a triad census + - Note existing scripts calling `node_in_regular()` will now return more correct results + - Moved former behaviour of `node_in_regular()` to `node_in_motif()`, + documented as capturing similarity of local embedding not role equivalence ## Motifs @@ -39,8 +45,8 @@ or its spread across layers in a multiplex network ## Methods -- Added `sim_rolesim()` and `sim_rege()`, recursive role similarity methods - - Note `sim_rege()` degenerate on unweighted connected ones, where it warns +- Added `regularity_rolesim()` and `regularity_rege()`, recursive role similarity methods + - Note `regularity_rege()` degenerate on unweighted connected ones, where it warns # netrics 0.4.1 diff --git a/R/member_equivalence.R b/R/member_equivalence.R index da21c05..50b626c 100644 --- a/R/member_equivalence.R +++ b/R/member_equivalence.R @@ -10,10 +10,12 @@ #' - `node_in_structural()` assigns nodes membership based on their #' having equivalent ties to the same other nodes. #' - `node_in_regular()` assigns nodes membership based on their -#' having equivalent patterns of ties. +#' having equivalent patterns of ties to equivalent others. #' - `node_in_automorphic()` assigns nodes membership based on their #' having equivalent distances to other nodes. -#' +#' - `node_in_motif()` assigns nodes membership based on their +#' participating in local structures at similar rates. +#' #' A `plot()` method exists for investigating the dendrogram #' of the hierarchical cluster and showing the returned cluster #' assignment. @@ -96,30 +98,89 @@ node_in_structural <- function(.data, } #' @rdname member_equivalence +#' @param regularity Character string indicating which algorithm should be +#' used to calculate how regularly equivalent nodes are. +#' By default `"rolesim"`; `"rege"` is also available. +#' Fewer, identifiable letters, e.g. `"ro"` for RoleSim, is sufficient. +#' See [regularity_rolesim()] and [regularity_rege()] for how they differ. +#' @param beta A decay parameter between 0 and 1 passed to [regularity_rolesim()], +#' controlling how much weight is given to the recursive component. +#' @section Regular equivalence: +#' Two nodes are regularly equivalent if each has ties to the same _kinds_ of +#' others, even where those others are not the same individuals and are not +#' equally numerous. A manager with three subordinates and a manager with ten +#' are regularly equivalent, because what makes them alike is that they both +#' have subordinates, not how many or which. +#' +#' The definition is recursive: nodes are equivalent if their alters are +#' equivalent, whose equivalence depends in turn on _their_ alters. +#' `node_in_regular()` therefore computes a similarity matrix by iterating +#' that definition to a fixed point, and then clusters it in the same way as +#' the other functions here. +#' +#' Note that this differs from `node_in_motif()`, which compares nodes on how +#' often they appear embedded in local structures. +#' Two nodes can have very similar triad profiles without being regularly equivalent, +#' and vice versa, since a motif census counts a node's local configurations +#' while regular equivalence asks who its alters are. #' @examples -#' (nre <- node_in_regular(ison_southern_women, -#' cluster = "concor")) +#' (nre <- node_in_regular(ison_southern_women)) #' @export -node_in_regular <- function(.data, +node_in_regular <- function(.data, k = c("silhouette", "elbow", "strict"), cluster = c("hierarchical", "concor","cosine"), - distance = c("euclidean", "maximum", "manhattan", + distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), - Kmax = 8L){ + Kmax = 8L, + regularity = c("rolesim", "rege"), + beta = 0.15){ + .data <- manynet::expect_nodes(.data) + regularity <- match.arg(regularity) + manynet::snet_info("Calculating regular equivalence using", + "{.fn regularity_{regularity}}.") + mat <- switch(regularity, + rolesim = regularity_rolesim(.data, beta = beta), + rege = regularity_rege(.data)) + node_in_equivalence(.data, mat, + k = k, cluster = cluster, distance = distance, Kmax = Kmax) +} + +#' @rdname member_equivalence +#' @section Motif equivalence: +#' Where the other functions here compare nodes on _whom_ they are tied to, +#' `node_in_motif()` compares them on _what kinds of local structure_ they sit +#' in, by clustering a census of the triads (or, for two-mode networks, +#' tetrads) each node participates in. +#' +#' This captures similarity of local embedding rather than equivalence of +#' role. It is well suited to distinguishing nodes that sit in dense, +#' closed neighbourhoods from those that bridge open ones, +#' but it is not regular equivalence: see `node_in_regular()` for that. +#' +#' This function was called `node_in_regular()` prior to version 0.5.0. +#' @examples +#' (nme <- node_in_motif(ison_southern_women, cluster = "concor")) +#' @export +node_in_motif <- function(.data, + k = c("silhouette", "elbow", "strict"), + cluster = c("hierarchical", "concor","cosine"), + distance = c("euclidean", "maximum", "manhattan", + "canberra", "binary", "minkowski"), + Kmax = 8L){ .data <- manynet::expect_nodes(.data) if(manynet::is_twomode(.data)){ - manynet::snet_info("Since this is a two-mode network,", - "using {.fn node_x_tetrad} to", + manynet::snet_info("Since this is a two-mode network,", + "using {.fn node_x_tetrad} to", "profile nodes' embedding in local structures.") mat <- as.matrix(node_x_tetrad(.data)) } else { - manynet::snet_info("Since this is a one-mode network,", - "using {.fn node_x_triad} to", + manynet::snet_info("Since this is a one-mode network,", + "using {.fn node_x_triad} to", "profile nodes' embedding in local structures.") mat <- node_x_triad(.data) } if(any(colSums(mat) == 0)) mat <- mat[,-which(colSums(mat) == 0)] - node_in_equivalence(.data, mat, + node_in_equivalence(.data, mat, k = k, cluster = cluster, distance = distance, Kmax = Kmax) } diff --git a/R/method_equivalence.R b/R/method_regularity.R similarity index 92% rename from R/method_equivalence.R rename to R/method_regularity.R index 982bca8..2f2d210 100644 --- a/R/method_equivalence.R +++ b/R/method_regularity.R @@ -1,13 +1,13 @@ # Recursive role similarity #### -#' Methods for calculating regular equivalence -#' @name method_equivalence +#' Methods for calculating regularity +#' @name method_regularity #' @description #' These functions calculate how regularly equivalent each pair of nodes is, #' returning a similarity matrix that [node_in_regular()] then clusters. #' -#' - `sim_rolesim()` calculates RoleSim similarity. -#' - `sim_rege()` calculates REGE similarity. +#' - `regularity_rolesim()` calculates RoleSim similarity. +#' - `regularity_rege()` calculates REGE similarity. #' #' Both are recursive: two nodes are similar to the extent that their alters #' are similar, which is the defining property of regular equivalence. @@ -16,7 +16,7 @@ #' @param beta A decay parameter between 0 and 1 controlling how much weight #' is given to the recursive component. By default 0.15. #' @param iterations Integer number of iterations. -#' By default 3 for `sim_rege()`; `sim_rolesim()` iterates to convergence. +#' By default 3 for `regularity_rege()`; `regularity_rolesim()` iterates to convergence. #' @returns A square similarity matrix with one row and column per node. #' @references #' ## On RoleSim @@ -34,7 +34,7 @@ #' @family methods NULL -#' @rdname method_equivalence +#' @rdname method_regularity #' @section RoleSim: #' RoleSim pairs up two nodes' alters by finding the _maximal matching_ #' between them, that is, the one-to-one pairing that maximises total @@ -49,7 +49,7 @@ NULL #' It converges to a unique solution regardless of where it starts, #' so the result does not depend on initialisation. #' @export -sim_rolesim <- function(.data, beta = 0.15){ +regularity_rolesim <- function(.data, beta = 0.15){ .data <- manynet::expect_nodes(.data) if(beta < 0 | beta > 1) manynet::snet_abort("`beta` must be a proportion between 0 and 1.") @@ -100,7 +100,7 @@ sim_rolesim <- function(.data, beta = 0.15){ total } -#' @rdname method_equivalence +#' @rdname method_regularity #' @section REGE: #' REGE instead pairs each alter with its _best_ counterpart, allowing the #' same alter to be used more than once: @@ -122,15 +122,15 @@ sim_rolesim <- function(.data, beta = 0.15){ #' an alter that matches every other node's alter perfectly, all nodes come #' out maximally equivalent, which is the correct but uninformative answer #' that the maximal regular equivalence of a connected graph is a single -#' class. Use `sim_rolesim()` for unweighted networks. +#' class. Use `regularity_rolesim()` for unweighted networks. #' @export -sim_rege <- function(.data, iterations = 3){ +regularity_rege <- function(.data, iterations = 3){ .data <- manynet::expect_nodes(.data) mat <- manynet::as_matrix(manynet::to_multilevel(.data)) if(!manynet::is_weighted(.data) && manynet::is_connected(.data)) manynet::snet_warn("REGE is degenerate on unweighted connected networks,", "where all nodes are maximally regularly equivalent.", - "Consider {.fn sim_rolesim} instead.") + "Consider {.fn regularity_rolesim} instead.") n <- nrow(mat) nbrs <- .neighbourhoods(mat, manynet::is_directed(.data)) sim <- matrix(1, n, n) # all nodes begin maximally similar diff --git a/R/motif_cliques.R b/R/motif_cliques.R index 3b6a634..25ddf0d 100644 --- a/R/motif_cliques.R +++ b/R/motif_cliques.R @@ -40,12 +40,13 @@ #' \doi{10.1007/BF02289146} #' @examples #' node_x_clique(ison_adolescents) -#' node_x_clique(ison_southern_women, min = c(3, 3)) +#' node_x_clique(ison_southern_women, min_clique_size = c(3, 3)) #' @export -node_x_clique <- function(.data, min = 3){ +node_x_clique <- function(.data, min_clique_size = 3){ .data <- manynet::expect_nodes(.data) twomode <- manynet::is_twomode(.data) - if(twomode && length(min) == 1) min <- c(min, min) + if(twomode && length(min_clique_size) == 1) + min_clique_size <- c(min_clique_size, min_clique_size) # a clique is a cohesive subgroup, so where ties are signed only the # positive ones can contribute to one if(manynet::is_signed(.data)) @@ -57,15 +58,16 @@ node_x_clique <- function(.data, min = 3){ # biclique becomes an ordinary clique of the combined node set mat <- ((mat %*% mat) + mat) > 0 diag(mat) <- 0 - smallest <- sum(min) - } else smallest <- min + smallest <- sum(min_clique_size) + } else smallest <- min_clique_size graph <- igraph::graph_from_adjacency_matrix(mat*1, mode = "undirected", diag = FALSE) cliques <- igraph::max_cliques(graph, min = smallest) if(twomode){ modes <- manynet::node_is_mode(.data) keep <- vapply(cliques, function(cl) - sum(!modes[cl]) >= min[1] && sum(modes[cl]) >= min[2], + sum(!modes[cl]) >= min_clique_size[1] && + sum(modes[cl]) >= min_clique_size[2], FUN.VALUE = logical(1)) cliques <- cliques[keep] } diff --git a/man/method_equivalence.Rd b/man/method_regularity.Rd similarity index 85% rename from man/method_equivalence.Rd rename to man/method_regularity.Rd index d533586..7425508 100644 --- a/man/method_equivalence.Rd +++ b/man/method_regularity.Rd @@ -1,14 +1,14 @@ % Generated by roxygen2: do not edit by hand -% Please edit documentation in R/method_equivalence.R -\name{method_equivalence} -\alias{method_equivalence} -\alias{sim_rolesim} -\alias{sim_rege} -\title{Methods for calculating regular equivalence} +% Please edit documentation in R/method_regularity.R +\name{method_regularity} +\alias{method_regularity} +\alias{regularity_rolesim} +\alias{regularity_rege} +\title{Methods for calculating regularity} \usage{ -sim_rolesim(.data, beta = 0.15) +regularity_rolesim(.data, beta = 0.15) -sim_rege(.data, iterations = 3) +regularity_rege(.data, iterations = 3) } \arguments{ \item{.data}{A network object of class \code{stocnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. @@ -19,7 +19,7 @@ For more information on possible coercions, see e.g. \code{\link[manynet:as_stoc is given to the recursive component. By default 0.15.} \item{iterations}{Integer number of iterations. -By default 3 for \code{sim_rege()}; \code{sim_rolesim()} iterates to convergence.} +By default 3 for \code{regularity_rege()}; \code{regularity_rolesim()} iterates to convergence.} } \value{ A square similarity matrix with one row and column per node. @@ -28,8 +28,8 @@ A square similarity matrix with one row and column per node. These functions calculate how regularly equivalent each pair of nodes is, returning a similarity matrix that \code{\link[=node_in_regular]{node_in_regular()}} then clusters. \itemize{ -\item \code{sim_rolesim()} calculates RoleSim similarity. -\item \code{sim_rege()} calculates REGE similarity. +\item \code{regularity_rolesim()} calculates RoleSim similarity. +\item \code{regularity_rege()} calculates REGE similarity. } Both are recursive: two nodes are similar to the extent that their alters @@ -74,7 +74,7 @@ On an unweighted, connected network it is degenerate: since every node has an alter that matches every other node's alter perfectly, all nodes come out maximally equivalent, which is the correct but uninformative answer that the maximal regular equivalence of a connected graph is a single -class. Use \code{sim_rolesim()} for unweighted networks. +class. Use \code{regularity_rolesim()} for unweighted networks. } \references{ diff --git a/tests/testthat/test-member_equivalence.R b/tests/testthat/test-member_equivalence.R index 42113e5..6278502 100644 --- a/tests/testthat/test-member_equivalence.R +++ b/tests/testthat/test-member_equivalence.R @@ -25,3 +25,50 @@ test_that("equivalence clustering works", { testthat::skip_if_not_installed("sna") expect_equal(c(net_nodes(ison_adolescents)), length(node_in_regular(ison_adolescents, "elbow"))) }) + +test_that("node_in_motif preserves the former census-based behaviour", { + expect_s3_class(node_in_motif(ison_adolescents), "node_member") + expect_equal(c(net_nodes(ison_southern_women)), + length(node_in_motif(ison_southern_women))) + # it is built on the triad census for one-mode networks, dropping any + # triad type that no node participates in + cens <- node_x_triad(ison_adolescents) + cens <- cens[, colSums(cens) != 0] + expect_equal(node_in_motif(ison_adolescents), + node_in_equivalence(ison_adolescents, cens)) +}) + +test_that("regularity_rolesim satisfies automorphic confirmation", { + # the spokes of a star are automorphically equivalent, so must score 1 + s <- regularity_rolesim(create_star(8)) + expect_true(all(s[2:8, 2:8] == 1)) + expect_lt(s[1, 2], 1) + # and it is a symmetric, bounded similarity with a unit diagonal + r <- regularity_rolesim(ison_adolescents) + expect_equal(r, t(r)) + expect_true(all(diag(r) == 1)) + expect_true(all(r >= 0 & r <= 1)) + expect_error(regularity_rolesim(ison_adolescents, beta = 2)) +}) + +test_that("regularity_rege discriminates on valued networks", { + r <- regularity_rege(ison_networkers) + expect_equal(r, t(r)) + expect_true(all(r >= 0 & r <= 1)) + 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)) +}) + +test_that("node_in_regular uses recursive similarity, not a census", { + expect_s3_class(node_in_regular(ison_southern_women), "node_member") + expect_s3_class(node_in_regular(ison_networkers, regularity = "rege"), + "node_member") + # the two algorithms need not agree, since they pair alters differently + expect_s3_class(node_in_regular(ison_algebra, regularity = "rege"), + "node_member") + # k remains the second argument, as in the sibling functions + expect_equal(node_in_regular(ison_adolescents, "strict"), + node_in_regular(ison_adolescents, k = "strict")) +}) From 986c26648dbc507dc7378f6f99449d493f5a3338 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 15:55:39 +0200 Subject: [PATCH 12/68] Added `net_by_inconsistency()` which scores how far a partition's blocks depart from ideal types --- NEWS.md | 3 + R/measure_features.R | 480 +++++++++++++++++-------- R/member_cliques.R | 37 -- man/measure_features.Rd | 109 +----- man/measure_fit.Rd | 264 ++++++++++++++ tests/testthat/test-measure_features.R | 14 - tests/testthat/test-measure_fit.R | 61 ++++ 7 files changed, 661 insertions(+), 307 deletions(-) create mode 100644 man/measure_fit.Rd create mode 100644 tests/testthat/test-measure_fit.R diff --git a/NEWS.md b/NEWS.md index 0fb592e..6175ea8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -12,6 +12,9 @@ - Added `net_by_cyclicality()` for detecting generalised exchange - Added `net_by_compactness()` for the average closeness of all pairs of nodes +- Added `net_by_inconsistency()`, which scores how far a partition's blocks depart + from ideal types (`nul`, `com`, `reg`, `rdo`, `cdo`, `dnc`), generalising + `net_by_factions()` beyond structural equivalence ## Memberships diff --git a/R/measure_features.R b/R/measure_features.R index e27dd25..1957588 100644 --- a/R/measure_features.R +++ b/R/measure_features.R @@ -3,17 +3,11 @@ #' Measuring network topological features #' @name measure_features #' @description -#' These functions measure certain topological features of networks: +#' These functions measure topological features that are intrinsic to a +#' network, in the sense that they require nothing of the user beyond the +#' network itself: #' -#' - `net_by_core()` measures the correlation between a network -#' and a core-periphery model with the same dimensions. #' - `net_by_richclub()` measures the rich-club coefficient of a network. -#' - `net_by_factions()` measures the correlation between a network -#' and a component model with the same dimensions. -#' If no 'membership' vector is given for the data, -#' `node_partition()` is used to partition nodes into two groups. -#' - `net_by_modularity()` measures the modularity of a network -#' based on nodes' membership in defined clusters. #' - `net_by_smallworld()` measures the small-world coefficient for one- or #' two-mode networks. Small-world networks can be highly clustered and yet #' have short path lengths. @@ -26,71 +20,10 @@ #' `1` if all triangles are balanced. #' #' @template param_data -#' @template param_memb +#' @family features #' @template net_measure NULL -#' @rdname measure_features -#' @param mark A logical vector indicating which nodes belong to the core. -#' @param method Which method of the following to use to calculate the fit of -#' the core assignment to a core-periphery model. -#' "correlation" calculates the correlation between the empirical network and -#' an ideal typical network, and "ident" calculates the Euclidean distances -#' between the same. -#' "ndiff", however, calculates how distinct the core and periphery groups are -#' based on the difference in coreness scores between the least core-like -#' member of the core and the most core-like member of the periphery. -#' "diff" is similar to "ndiff", but multiplies the raw "ndiff" score by the -#' square root of the size of the core, thus penalising large cores. -#' @section Core-Periphery: -#' `net_core()` calculates the Pearson correlation between the given network, -#' where the nodes in the core are assigned by some given mark, and an ideal -#' typical core-periphery network with the same number of nodes in the core -#' and the periphery. -#' @references -#' ## On core-periphery -#' Borgatti, Stephen P., and Martin G. Everett. 2000. -#' “Models of Core/Periphery Structures.” -#' _Social Networks_ 21(4):375–95. -#' \doi{10.1016/S0378-8733(99)00019-2} -#' @examples -#' net_by_core(ison_adolescents) -#' net_by_core(ison_southern_women) -#' @export -net_by_core <- function(.data, - mark = NULL, - method = c("correlation","ident","ndiff", "diff")){ - .data <- manynet::expect_nodes(.data) - if(is.null(mark)) mark <- node_is_core(.data) - - method <- match.arg(method) - if(method == "correlation"){ - out <- stats::cor(c(manynet::as_matrix(.data)), - c(manynet::as_matrix(manynet::create_core(.data, mark = mark)))) - } else if(method == "ident"){ - out <- sqrt(sum((manynet::as_matrix(.data) - - manynet::as_matrix(manynet::create_core(.data, mark = mark)))^2)) - } else if(method %in% c("ndiff","diff")){ - # Sort nodes by coreness - c_scores <- node_by_coreness(.data) - core <- c_scores[mark] - periphery <- c_scores[!mark] - - min_core <- min(core) - max_periphery <- max(periphery) - - diff1 <- sum(min_core - periphery) - diff2 <- sum(core - max_periphery) - - if(method == "ndiff"){ - out <- (diff1 + diff2) / length(c_scores) # Normalize - } else if(method == "diff"){ - out <- (diff1 + diff2) * sqrt(sum(mark)) - } - } else manynet::snet_unavailable(method) - make_network_measure(out, .data, call = deparse(sys.call())) -} - #' @rdname measure_features #' @references #' ## On the rich-club coefficient @@ -142,91 +75,6 @@ net_by_richclub <- function(.data){ # max(coefs, na.rm = TRUE) make_network_measure(out, .data, call = deparse(sys.call())) } - -#' @rdname measure_features -#' @examples -#' net_by_factions(ison_southern_women) -#' @export -net_by_factions <- function(.data, - membership = NULL){ - .data <- manynet::expect_nodes(.data) - membership <- .resolve_membership(.data, membership) - if(is.null(membership)){ - manynet::snet_info("No membership vector assigned.", - "Partitioning the network using {.fn node_in_partition}.") - membership <- node_in_partition(.data) - } - out <- stats::cor(c(manynet::as_matrix(.data)), - c(manynet::as_matrix(manynet::create_components(.data, - membership = membership)))) - make_network_measure(out, .data, call = deparse(sys.call())) -} - -#' @rdname measure_features -#' @section Modularity: -#' Modularity measures the difference between the number of ties within each community -#' from the number of ties expected within each community in a random graph -#' with the same degrees, and ranges between -1 and +1. -#' Modularity scores of +1 mean that ties only appear within communities, -#' while -1 would mean that ties only appear between communities. -#' A score of 0 would mean that ties are half within and half between communities, -#' as one would expect in a random graph. -#' -#' Modularity faces a difficult problem known as the resolution limit -#' (Fortunato and Barthélemy 2007). -#' This problem appears when optimising modularity, -#' particularly with large networks or depending on the degree of interconnectedness, -#' can miss small clusters that 'hide' inside larger clusters. -#' In the extreme case, this can be where they are only connected -#' to the rest of the network through a single tie. -#' To help manage this problem, a `resolution` parameter is added. -#' Please see the argument definition for more details. -#' @param resolution A proportion indicating the resolution scale. -#' By default 1, which returns the original definition of modularity. -#' The higher this parameter, the more smaller communities will be privileged. -#' The lower this parameter, the fewer larger communities are likely to be found. -#' @examples -#' net_by_modularity(ison_adolescents, -#' node_in_partition(ison_adolescents)) -#' net_by_modularity(ison_southern_women, -#' node_in_partition(ison_southern_women)) -#' @references -#' ## On modularity -#' Newman, Mark E.J. 2006. -#' "Modularity and community structure in networks", -#' _Proceedings of the National Academy of Sciences_ 103(23): 8577-8696. -#' \doi{https://doi.org/10.1073/pnas.0601602103} -#' -#' Murata, Tsuyoshi. 2010. -#' "Modularity for Bipartite Networks". -#' In: Memon, N., Xu, J., Hicks, D., Chen, H. (eds) -#' _Data Mining for Social Network Data. Annals of Information Systems_, Vol 12. -#' Springer, Boston, MA. -#' \doi{10.1007/978-1-4419-6287-4_7} -#' @export -net_by_modularity <- function(.data, - membership = NULL, - resolution = 1){ - .data <- manynet::expect_nodes(.data) - membership <- .resolve_membership(.data, membership) - if(is.null(membership)){ - manynet::snet_info("Since no membership argument has been provided,", - "a partition of the network into two will be calculated and used.") - membership <- node_in_partition(.data) - } - if(!is.numeric(membership)) membership <- as.numeric(as.factor(membership)) - if(!manynet::is_graph(.data)) .data <- as_igraph(.data) - if(manynet::is_twomode(.data)){ - make_network_measure(igraph::modularity(manynet::to_multilevel(.data), - membership = membership, - resolution = resolution), - .data, call = deparse(sys.call())) - } else make_network_measure(igraph::modularity(.data, - membership = membership, - resolution = resolution), - .data, call = deparse(sys.call())) -} - #' @rdname measure_features #' @param times Integer of number of simulations. #' @param method There are three small-world measures implemented: @@ -312,7 +160,6 @@ net_by_smallworld <- function(.data, make_network_measure(out, .data, call = deparse(sys.call())) } - #' @rdname measure_features #' @importFrom igraph fit_power_law #' @references @@ -350,7 +197,6 @@ net_by_scalefree <- function(.data){ make_network_measure(out$alpha, .data, call = deparse(sys.call())) } - #' @rdname measure_features #' @source `{signnet}` by David Schoch #' @references @@ -430,3 +276,321 @@ net_by_balance <- function(.data) { call = deparse(sys.call())) } +# Structural fit #### + +#' Measuring how well a structure fits a network +#' @name measure_fit +#' @description +#' These functions measure how well some proposed structure describes a +#' network. Unlike the intrinsic properties in [measure_features], each takes +#' a structure from the user — a core-periphery mark, or a partition of the +#' nodes — and returns how closely the observed network corresponds to it: +#' +#' - `net_by_core()` measures the correlation between a network +#' and a core-periphery model with the same dimensions. +#' - `net_by_factions()` measures the correlation between a network +#' and a component model with the same dimensions. +#' - `net_by_modularity()` measures the modularity of a network +#' based on nodes' membership in defined clusters. +#' - `net_by_inconsistency()` measures how far a partition's blocks depart from +#' ideal block types. +#' +#' These are the natural companions to the `node_in_*()` functions, which +#' propose a structure; these say how good that proposal is. +#' Where a partition is expected but none is given, the network is +#' partitioned into two using [node_in_partition()]. +#' +#' Note that they are not on a common scale, and do not all run in the same +#' direction, so they are not interchangeable: +#' +#' | measure | compares the network against | range | better | +#' | --- | --- | --- | --- | +#' | `net_by_core()` | a core-periphery model | -1 to 1 | higher | +#' | `net_by_factions()` | a components model | -1 to 1 | higher | +#' | `net_by_modularity()` | the partition's communities | -0.5 to 1 | higher | +#' | `net_by_inconsistency()` | ideal block types | 0 upwards | **lower** | +#' +#' Compare partitions using one measure at a time. +#' +#' @template param_data +#' @template param_memb +#' @family features +#' @template net_measure +NULL + +#' @rdname measure_fit +#' @param mark A logical vector indicating which nodes belong to the core. +#' @param method Which method of the following to use to calculate the fit of +#' the core assignment to a core-periphery model. +#' "correlation" calculates the correlation between the empirical network and +#' an ideal typical network, and "ident" calculates the Euclidean distances +#' between the same. +#' "ndiff", however, calculates how distinct the core and periphery groups are +#' based on the difference in coreness scores between the least core-like +#' member of the core and the most core-like member of the periphery. +#' "diff" is similar to "ndiff", but multiplies the raw "ndiff" score by the +#' square root of the size of the core, thus penalising large cores. +#' @section Core-Periphery: +#' `net_core()` calculates the Pearson correlation between the given network, +#' where the nodes in the core are assigned by some given mark, and an ideal +#' typical core-periphery network with the same number of nodes in the core +#' and the periphery. +#' @references +#' ## On core-periphery +#' Borgatti, Stephen P., and Martin G. Everett. 2000. +#' “Models of Core/Periphery Structures.” +#' _Social Networks_ 21(4):375–95. +#' \doi{10.1016/S0378-8733(99)00019-2} +#' @examples +#' net_by_core(ison_adolescents) +#' net_by_core(ison_southern_women) +#' @export +net_by_core <- function(.data, + mark = NULL, + method = c("correlation","ident","ndiff", "diff")){ + .data <- manynet::expect_nodes(.data) + if(is.null(mark)) mark <- node_is_core(.data) + + method <- match.arg(method) + if(method == "correlation"){ + out <- stats::cor(c(manynet::as_matrix(.data)), + c(manynet::as_matrix(manynet::create_core(.data, mark = mark)))) + } else if(method == "ident"){ + out <- sqrt(sum((manynet::as_matrix(.data) - + manynet::as_matrix(manynet::create_core(.data, mark = mark)))^2)) + } else if(method %in% c("ndiff","diff")){ + # Sort nodes by coreness + c_scores <- node_by_coreness(.data) + core <- c_scores[mark] + periphery <- c_scores[!mark] + + min_core <- min(core) + max_periphery <- max(periphery) + + diff1 <- sum(min_core - periphery) + diff2 <- sum(core - max_periphery) + + if(method == "ndiff"){ + out <- (diff1 + diff2) / length(c_scores) # Normalize + } else if(method == "diff"){ + out <- (diff1 + diff2) * sqrt(sum(mark)) + } + } else manynet::snet_unavailable(method) + make_network_measure(out, .data, call = deparse(sys.call())) +} + +#' @rdname measure_fit +#' @examples +#' net_by_factions(ison_southern_women) +#' @export +net_by_factions <- function(.data, + membership = NULL){ + .data <- manynet::expect_nodes(.data) + membership <- .resolve_membership(.data, membership) + if(is.null(membership)){ + manynet::snet_info("No membership vector assigned.", + "Partitioning the network using {.fn node_in_partition}.") + membership <- node_in_partition(.data) + } + out <- stats::cor(c(manynet::as_matrix(.data)), + c(manynet::as_matrix(manynet::create_components(.data, + membership = membership)))) + make_network_measure(out, .data, call = deparse(sys.call())) +} + +#' @rdname measure_fit +#' @section Modularity: +#' Modularity measures the difference between the number of ties within each community +#' from the number of ties expected within each community in a random graph +#' with the same degrees, and ranges between -1 and +1. +#' Modularity scores of +1 mean that ties only appear within communities, +#' while -1 would mean that ties only appear between communities. +#' A score of 0 would mean that ties are half within and half between communities, +#' as one would expect in a random graph. +#' +#' Modularity faces a difficult problem known as the resolution limit +#' (Fortunato and Barthélemy 2007). +#' This problem appears when optimising modularity, +#' particularly with large networks or depending on the degree of interconnectedness, +#' can miss small clusters that 'hide' inside larger clusters. +#' In the extreme case, this can be where they are only connected +#' to the rest of the network through a single tie. +#' To help manage this problem, a `resolution` parameter is added. +#' Please see the argument definition for more details. +#' @param resolution A proportion indicating the resolution scale. +#' By default 1, which returns the original definition of modularity. +#' The higher this parameter, the more smaller communities will be privileged. +#' The lower this parameter, the fewer larger communities are likely to be found. +#' @examples +#' net_by_modularity(ison_adolescents, +#' node_in_partition(ison_adolescents)) +#' net_by_modularity(ison_southern_women, +#' node_in_partition(ison_southern_women)) +#' @references +#' ## On modularity +#' Newman, Mark E.J. 2006. +#' "Modularity and community structure in networks", +#' _Proceedings of the National Academy of Sciences_ 103(23): 8577-8696. +#' \doi{https://doi.org/10.1073/pnas.0601602103} +#' +#' Murata, Tsuyoshi. 2010. +#' "Modularity for Bipartite Networks". +#' In: Memon, N., Xu, J., Hicks, D., Chen, H. (eds) +#' _Data Mining for Social Network Data. Annals of Information Systems_, Vol 12. +#' Springer, Boston, MA. +#' \doi{10.1007/978-1-4419-6287-4_7} +#' @export +net_by_modularity <- function(.data, + membership = NULL, + resolution = 1){ + .data <- manynet::expect_nodes(.data) + membership <- .resolve_membership(.data, membership) + if(is.null(membership)){ + manynet::snet_info("Since no membership argument has been provided,", + "a partition of the network into two will be calculated and used.") + membership <- node_in_partition(.data) + } + if(!is.numeric(membership)) membership <- as.numeric(as.factor(membership)) + if(!manynet::is_graph(.data)) .data <- as_igraph(.data) + if(manynet::is_twomode(.data)){ + make_network_measure(igraph::modularity(manynet::to_multilevel(.data), + membership = membership, + resolution = resolution), + .data, call = deparse(sys.call())) + } else make_network_measure(igraph::modularity(.data, + membership = membership, + resolution = resolution), + .data, call = deparse(sys.call())) +} + +#' @rdname measure_fit +#' @param blocks A character vector of permitted ideal block types, +#' or a list-matrix giving the permitted types for each block position. +#' By default `c("nul", "com")`, which is structural blockmodelling. +#' See the section below. +#' @section Blockmodelling: +#' A blockmodel proposes that a partition reduces a network to a small number +#' of positions, so that every block — the ties running from one position to +#' another — is of some simple ideal type. +#' `net_by_inconsistency()` measures how far the network departs from that proposal, +#' by counting the ties that would have to be added or removed to make every +#' block ideal, normalized by the number of cells. +#' **Lower is better**: 0 means the partition fits perfectly. +#' +#' This is a _distance_ from an ideal image rather than a measure of fit — +#' hence the name, and hence its running the opposite way to the rest of this +#' page. Three consequences are worth knowing: +#' +#' - **Its complement is not a proportion.** The criterion mixes units: `nul` +#' and `com` count cells, while `reg` counts empty rows and columns, and all +#' are divided by the cell count. So do not read \eqn{1 - x} as the share of +#' the network that the blockmodel gets right. +#' - **It is not bounded above by 1.** That holds only for cell-counting +#' vocabularies such as `c("nul", "com")`. With `reg` permitted it can exceed +#' 1 — on `ison_adolescents`, `blocks = "reg"` over singleton positions +#' reaches about 1.57. +#' - **The vocabularies behave very differently at fine partitions.** Giving +#' every node its own position scores 0 under `c("nul", "com")`, since each +#' block is then a single cell and trivially ideal, but scores its _worst_ +#' under `"reg"`, since each block then has an empty row and column. +#' +#' For a correlation-scaled, higher-is-better reading of the common structural +#' case, see [net_by_factions()]. The two are related but not equivalent: +#' `net_by_factions()` fixes the image — complete on the diagonal, null off it +#' — whereas `net_by_inconsistency(blocks = c("nul", "com"))` lets each block take +#' whichever of the two ideals fits it better, and so is more permissive. +#' +#' The ideal types are: +#' \describe{ +#' \item{`nul`}{a null block, containing no ties.} +#' \item{`com`}{a complete block, containing every possible tie.} +#' \item{`reg`}{a regular block, in which every row and every column has at +#' least one tie, though not necessarily all of them.} +#' \item{`rdo`, `cdo`}{a row- or column-dominant block, containing at least +#' one complete row or column.} +#' \item{`dnc`}{"do not care": a block left unconstrained.} +#' } +#' +#' `blocks` is a _vocabulary_ rather than an assignment: each block is scored +#' at the lowest inconsistency of any permitted type, and the results summed. +#' Any subset may be given, and the two conventional choices are +#' `c("nul", "com")` for structural equivalence and `c("nul", "reg")` for +#' regular equivalence. +#' +#' Note that permitting more types can only lower the criterion, since each +#' block gains more ways to be satisfied. The size of the vocabulary is +#' therefore itself a modelling choice, and criterion values are comparable +#' across partitions only when the same vocabulary is used for each. +#' +#' For fully generalized blockmodelling, pass a `g` by `g` list-matrix +#' naming the types permitted at each position separately, +#' e.g. `reg` on the diagonal and `nul` off it for a "cohesive positions" +#' model. +#' @references +#' ## On generalized blockmodelling +#' Doreian, Patrick, Vladimir Batagelj, and Anuska Ferligoj. 2005. +#' _Generalized Blockmodeling_. +#' Cambridge: Cambridge University Press. +#' \doi{10.1017/CBO9780511584176} +#' @examples +#' net_by_inconsistency(ison_hightech, node_in_regular(ison_hightech)) +#' # a regular-equivalence vocabulary instead of a structural one +#' net_by_inconsistency(ison_hightech, node_in_structural(ison_hightech), +#' blocks = c("nul", "reg")) +#' @export +net_by_inconsistency <- function(.data, membership = NULL, + blocks = c("nul", "com")){ + .data <- manynet::expect_nodes(.data) + membership <- .resolve_membership(.data, membership) + if(is.null(membership)){ + manynet::snet_info("No membership vector assigned.", + "Partitioning the network using {.fn node_in_partition}.") + membership <- node_in_partition(.data) + } + mat <- manynet::as_matrix(manynet::to_unweighted(manynet::to_multilevel(.data))) + memb <- as.numeric(as.factor(membership)) + g <- max(memb) + loops <- manynet::is_complex(.data) + total <- 0 + for(i in seq_len(g)) for(j in seq_len(g)){ + permitted <- .permitted_blocks(blocks, i, j) + sub <- mat[memb == i, memb == j, drop = FALSE] + # a node cannot be tied to itself unless the network is complex, so + # the diagonal of a diagonal block is not evidence either way + if(i == j && !loops) diag(sub) <- NA + total <- total + min(vapply(permitted, .block_inconsistency, sub, + FUN.VALUE = numeric(1))) + } + cells <- if(loops) length(mat) else length(mat) - nrow(mat) + make_network_measure(total/cells, .data, call = deparse(sys.call())) +} + +# Resolve the vocabulary permitted at block position (i,j), which is either +# shared across all blocks or given per position for generalized blockmodelling. +.permitted_blocks <- function(blocks, i, j){ + out <- if(is.matrix(blocks) || is.list(blocks) && !is.null(dim(blocks))) + blocks[[i, j]] else blocks + out <- match.arg(out, c("nul", "com", "reg", "rdo", "cdo", "dnc"), + several.ok = TRUE) + out +} + +# The number of ties that would have to be added to, or removed from, a block +# for it to match a given ideal type. NA cells (a diagonal block's diagonal) +# are not counted either way. +.block_inconsistency <- function(type, sub){ + if(length(sub) == 0) return(0) + switch(type, + # a null block should be empty, so every tie present is an error + nul = sum(sub, na.rm = TRUE), + # a complete block should be full, so every tie absent is an error + com = sum(sub == 0, na.rm = TRUE), + # a regular block needs every row and column to be non-empty + reg = sum(rowSums(sub, na.rm = TRUE) == 0) + + sum(colSums(sub, na.rm = TRUE) == 0), + # a dominant block needs one complete row (or column), so the error + # is how far the nearest row (column) falls short of being complete + rdo = min(rowSums(sub == 0, na.rm = TRUE)), + cdo = min(colSums(sub == 0, na.rm = TRUE)), + dnc = 0) +} diff --git a/R/member_cliques.R b/R/member_cliques.R index c224249..8af248d 100644 --- a/R/member_cliques.R +++ b/R/member_cliques.R @@ -90,40 +90,3 @@ node_in_roulette <- function(.data, num_groups, group_size, times = NULL){ .to_cliques <- function(member){ (member == t(matrix(member, length(member), length(member))))*1 } - -.weakPerturb <- function(soln){ - gsizes <- table(soln) - evens <- all(gsizes == max(gsizes)) - if(evens){ - soln <- .swapMove(soln) - } else { - if(stats::runif(1)<0.5) soln <- .swapMove(soln) else - soln <- .oneMove(soln) - } - soln -} - -.swapMove <- function(soln){ - from <- sample(seq.int(length(soln)), 1) - to <- sample(which(soln != soln[from]), 1) - soln[c(to,from)] <- soln[c(from,to)] - soln -} - -.oneMove <- function(soln){ - gsizes <- table(soln) - maxg <- which(gsizes == max(gsizes)) - from <- sample(which(soln %in% maxg), 1) - soln[from] <- sample(which(gsizes != max(gsizes)), 1) - soln -} - -.strongPerturb <- function(soln, strength = 1){ - times <- ceiling(strength * length(soln)/max(soln)) - for (t in seq.int(times)){ - soln <- .weakPerturb(soln) - } - soln -} - - diff --git a/man/measure_features.Rd b/man/measure_features.Rd index 1b79cf3..a36b021 100644 --- a/man/measure_features.Rd +++ b/man/measure_features.Rd @@ -2,10 +2,7 @@ % Please edit documentation in R/measure_features.R \name{measure_features} \alias{measure_features} -\alias{net_by_core} \alias{net_by_richclub} -\alias{net_by_factions} -\alias{net_by_modularity} \alias{net_by_smallworld} \alias{net_by_scalefree} \alias{net_by_balance} @@ -14,18 +11,8 @@ \code{{signnet}} by David Schoch } \usage{ -net_by_core( - .data, - mark = NULL, - method = c("correlation", "ident", "ndiff", "diff") -) - net_by_richclub(.data) -net_by_factions(.data, membership = NULL) - -net_by_modularity(.data, membership = NULL, resolution = 1) - net_by_smallworld(.data, method = c("omega", "sigma", "SWI"), times = 100) net_by_scalefree(.data) @@ -33,11 +20,9 @@ net_by_scalefree(.data) net_by_balance(.data) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} - -\item{mark}{A logical vector indicating which nodes belong to the core.} +\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{method}{There are three small-world measures implemented: \itemize{ @@ -63,35 +48,17 @@ with the same dimensions. but where there may not be a network for which \eqn{SWI = 1}. }} -\item{membership}{A character string naming an existing node attribute in -the network, or a categorical vector of the same length as the number of -nodes in the network where each element indicates the group membership of -the corresponding node. -While this may often be a vector created using \verb{node_in_*()} functions, -it can be any character vector that assigns nodes to groups or categories.} - -\item{resolution}{A proportion indicating the resolution scale. -By default 1, which returns the original definition of modularity. -The higher this parameter, the more smaller communities will be privileged. -The lower this parameter, the fewer larger communities are likely to be found.} - \item{times}{Integer of number of simulations.} } \value{ A \code{network_measure} numeric score. } \description{ -These functions measure certain topological features of networks: +These functions measure topological features that are intrinsic to a +network, in the sense that they require nothing of the user beyond the +network itself: \itemize{ -\item \code{net_by_core()} measures the correlation between a network -and a core-periphery model with the same dimensions. \item \code{net_by_richclub()} measures the rich-club coefficient of a network. -\item \code{net_by_factions()} measures the correlation between a network -and a component model with the same dimensions. -If no 'membership' vector is given for the data, -\code{node_partition()} is used to partition nodes into two groups. -\item \code{net_by_modularity()} measures the modularity of a network -based on nodes' membership in defined clusters. \item \code{net_by_smallworld()} measures the small-world coefficient for one- or two-mode networks. Small-world networks can be highly clustered and yet have short path lengths. @@ -104,44 +71,8 @@ ranging between \code{0} if all triangles are imbalanced and \code{1} if all triangles are balanced. } } -\section{Core-Periphery}{ - -\code{net_core()} calculates the Pearson correlation between the given network, -where the nodes in the core are assigned by some given mark, and an ideal -typical core-periphery network with the same number of nodes in the core -and the periphery. -} - -\section{Modularity}{ - -Modularity measures the difference between the number of ties within each community -from the number of ties expected within each community in a random graph -with the same degrees, and ranges between -1 and +1. -Modularity scores of +1 mean that ties only appear within communities, -while -1 would mean that ties only appear between communities. -A score of 0 would mean that ties are half within and half between communities, -as one would expect in a random graph. - -Modularity faces a difficult problem known as the resolution limit -(Fortunato and Barthélemy 2007). -This problem appears when optimising modularity, -particularly with large networks or depending on the degree of interconnectedness, -can miss small clusters that 'hide' inside larger clusters. -In the extreme case, this can be where they are only connected -to the rest of the network through a single tie. -To help manage this problem, a \code{resolution} parameter is added. -Please see the argument definition for more details. -} - \examples{ -net_by_core(ison_adolescents) -net_by_core(ison_southern_women) net_by_richclub(ison_adolescents) - net_by_factions(ison_southern_women) -net_by_modularity(ison_adolescents, - node_in_partition(ison_adolescents)) -net_by_modularity(ison_southern_women, - node_in_partition(ison_southern_women)) net_by_smallworld(ison_brandes) net_by_smallworld(ison_southern_women) net_by_scalefree(ison_adolescents) @@ -150,14 +81,6 @@ net_by_scalefree(create_lattice(100)) net_by_balance(to_uniplex(fict_marvel, "relationship")) } \references{ -\subsection{On core-periphery}{ - -Borgatti, Stephen P., and Martin G. Everett. 2000. -“Models of Core/Periphery Structures.” -\emph{Social Networks} 21(4):375–95. -\doi{10.1016/S0378-8733(99)00019-2} -} - \subsection{On the rich-club coefficient}{ Zhou, Shi, and Raul J. Mondragon. 2004. @@ -166,21 +89,6 @@ Zhou, Shi, and Raul J. Mondragon. 2004. \doi{10.1109/lcomm.2004.823426} } -\subsection{On modularity}{ - -Newman, Mark E.J. 2006. -"Modularity and community structure in networks", -\emph{Proceedings of the National Academy of Sciences} 103(23): 8577-8696. -\doi{https://doi.org/10.1073/pnas.0601602103} - -Murata, Tsuyoshi. 2010. -"Modularity for Bipartite Networks". -In: Memon, N., Xu, J., Hicks, D., Chen, H. (eds) -\emph{Data Mining for Social Network Data. Annals of Information Systems}, Vol 12. -Springer, Boston, MA. -\doi{10.1007/978-1-4419-6287-4_7} -} - \subsection{On small-worldliness}{ Watts, Duncan J., and Steven H. Strogatz. 1998. @@ -239,6 +147,9 @@ Cartwright, D., and Frank Harary. 1956. \code{\link[=net_by_transitivity]{net_by_transitivity()}} and \code{\link[=net_by_equivalency]{net_by_equivalency()}} for how clustering is calculated +Other features: +\code{\link{measure_fit}} + Other measures: \code{\link{measure_assort_net}}, \code{\link{measure_assort_node}}, @@ -263,8 +174,10 @@ Other measures: \code{\link{measure_diffusion_node}}, \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} } +\concept{features} \concept{measures} diff --git a/man/measure_fit.Rd b/man/measure_fit.Rd new file mode 100644 index 0000000..8a289b5 --- /dev/null +++ b/man/measure_fit.Rd @@ -0,0 +1,264 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/measure_features.R +\name{measure_fit} +\alias{measure_fit} +\alias{net_by_core} +\alias{net_by_factions} +\alias{net_by_modularity} +\alias{net_by_inconsistency} +\title{Measuring how well a structure fits a network} +\usage{ +net_by_core( + .data, + mark = NULL, + method = c("correlation", "ident", "ndiff", "diff") +) + +net_by_factions(.data, membership = NULL) + +net_by_modularity(.data, membership = NULL, resolution = 1) + +net_by_inconsistency(.data, membership = NULL, blocks = c("nul", "com")) +} +\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{mark}{A logical vector indicating which nodes belong to the core.} + +\item{method}{Which method of the following to use to calculate the fit of +the core assignment to a core-periphery model. +"correlation" calculates the correlation between the empirical network and +an ideal typical network, and "ident" calculates the Euclidean distances +between the same. +"ndiff", however, calculates how distinct the core and periphery groups are +based on the difference in coreness scores between the least core-like +member of the core and the most core-like member of the periphery. +"diff" is similar to "ndiff", but multiplies the raw "ndiff" score by the +square root of the size of the core, thus penalising large cores.} + +\item{membership}{A character string naming an existing node attribute in +the network, or a categorical vector of the same length as the number of +nodes in the network where each element indicates the group membership of +the corresponding node. +While this may often be a vector created using \verb{node_in_*()} functions, +it can be any character vector that assigns nodes to groups or categories.} + +\item{resolution}{A proportion indicating the resolution scale. +By default 1, which returns the original definition of modularity. +The higher this parameter, the more smaller communities will be privileged. +The lower this parameter, the fewer larger communities are likely to be found.} + +\item{blocks}{A character vector of permitted ideal block types, +or a list-matrix giving the permitted types for each block position. +By default \code{c("nul", "com")}, which is structural blockmodelling. +See the section below.} +} +\value{ +A \code{network_measure} numeric score. +} +\description{ +These functions measure how well some proposed structure describes a +network. Unlike the intrinsic properties in \link{measure_features}, each takes +a structure from the user — a core-periphery mark, or a partition of the +nodes — and returns how closely the observed network corresponds to it: +\itemize{ +\item \code{net_by_core()} measures the correlation between a network +and a core-periphery model with the same dimensions. +\item \code{net_by_factions()} measures the correlation between a network +and a component model with the same dimensions. +\item \code{net_by_modularity()} measures the modularity of a network +based on nodes' membership in defined clusters. +\item \code{net_by_inconsistency()} measures how far a partition's blocks depart from +ideal block types. +} + +These are the natural companions to the \verb{node_in_*()} functions, which +propose a structure; these say how good that proposal is. +Where a partition is expected but none is given, the network is +partitioned into two using \code{\link[=node_in_partition]{node_in_partition()}}. + +Note that they are not on a common scale, and do not all run in the same +direction, so they are not interchangeable:\tabular{llll}{ + measure \tab compares the network against \tab range \tab better \cr + \code{net_by_core()} \tab a core-periphery model \tab -1 to 1 \tab higher \cr + \code{net_by_factions()} \tab a components model \tab -1 to 1 \tab higher \cr + \code{net_by_modularity()} \tab the partition's communities \tab -0.5 to 1 \tab higher \cr + \code{net_by_inconsistency()} \tab ideal block types \tab 0 upwards \tab \strong{lower} \cr +} + + +Compare partitions using one measure at a time. +} +\section{Core-Periphery}{ + +\code{net_core()} calculates the Pearson correlation between the given network, +where the nodes in the core are assigned by some given mark, and an ideal +typical core-periphery network with the same number of nodes in the core +and the periphery. +} + +\section{Modularity}{ + +Modularity measures the difference between the number of ties within each community +from the number of ties expected within each community in a random graph +with the same degrees, and ranges between -1 and +1. +Modularity scores of +1 mean that ties only appear within communities, +while -1 would mean that ties only appear between communities. +A score of 0 would mean that ties are half within and half between communities, +as one would expect in a random graph. + +Modularity faces a difficult problem known as the resolution limit +(Fortunato and Barthélemy 2007). +This problem appears when optimising modularity, +particularly with large networks or depending on the degree of interconnectedness, +can miss small clusters that 'hide' inside larger clusters. +In the extreme case, this can be where they are only connected +to the rest of the network through a single tie. +To help manage this problem, a \code{resolution} parameter is added. +Please see the argument definition for more details. +} + +\section{Blockmodelling}{ + +A blockmodel proposes that a partition reduces a network to a small number +of positions, so that every block — the ties running from one position to +another — is of some simple ideal type. +\code{net_by_inconsistency()} measures how far the network departs from that proposal, +by counting the ties that would have to be added or removed to make every +block ideal, normalized by the number of cells. +\strong{Lower is better}: 0 means the partition fits perfectly. + +This is a \emph{distance} from an ideal image rather than a measure of fit — +hence the name, and hence its running the opposite way to the rest of this +page. Three consequences are worth knowing: +\itemize{ +\item \strong{Its complement is not a proportion.} The criterion mixes units: \code{nul} +and \code{com} count cells, while \code{reg} counts empty rows and columns, and all +are divided by the cell count. So do not read \eqn{1 - x} as the share of +the network that the blockmodel gets right. +\item \strong{It is not bounded above by 1.} That holds only for cell-counting +vocabularies such as \code{c("nul", "com")}. With \code{reg} permitted it can exceed +1 — on \code{ison_adolescents}, \code{blocks = "reg"} over singleton positions +reaches about 1.57. +\item \strong{The vocabularies behave very differently at fine partitions.} Giving +every node its own position scores 0 under \code{c("nul", "com")}, since each +block is then a single cell and trivially ideal, but scores its \emph{worst} +under \code{"reg"}, since each block then has an empty row and column. +} + +For a correlation-scaled, higher-is-better reading of the common structural +case, see \code{\link[=net_by_factions]{net_by_factions()}}. The two are related but not equivalent: +\code{net_by_factions()} fixes the image — complete on the diagonal, null off it +— whereas \code{net_by_inconsistency(blocks = c("nul", "com"))} lets each block take +whichever of the two ideals fits it better, and so is more permissive. + +The ideal types are: +\describe{ +\item{\code{nul}}{a null block, containing no ties.} +\item{\code{com}}{a complete block, containing every possible tie.} +\item{\code{reg}}{a regular block, in which every row and every column has at +least one tie, though not necessarily all of them.} +\item{\code{rdo}, \code{cdo}}{a row- or column-dominant block, containing at least +one complete row or column.} +\item{\code{dnc}}{"do not care": a block left unconstrained.} +} + +\code{blocks} is a \emph{vocabulary} rather than an assignment: each block is scored +at the lowest inconsistency of any permitted type, and the results summed. +Any subset may be given, and the two conventional choices are +\code{c("nul", "com")} for structural equivalence and \code{c("nul", "reg")} for +regular equivalence. + +Note that permitting more types can only lower the criterion, since each +block gains more ways to be satisfied. The size of the vocabulary is +therefore itself a modelling choice, and criterion values are comparable +across partitions only when the same vocabulary is used for each. + +For fully generalized blockmodelling, pass a \code{g} by \code{g} list-matrix +naming the types permitted at each position separately, +e.g. \code{reg} on the diagonal and \code{nul} off it for a "cohesive positions" +model. +} + +\examples{ +net_by_core(ison_adolescents) +net_by_core(ison_southern_women) + net_by_factions(ison_southern_women) +net_by_modularity(ison_adolescents, + node_in_partition(ison_adolescents)) +net_by_modularity(ison_southern_women, + node_in_partition(ison_southern_women)) +net_by_inconsistency(ison_hightech, node_in_regular(ison_hightech)) +# a regular-equivalence vocabulary instead of a structural one +net_by_inconsistency(ison_hightech, node_in_structural(ison_hightech), + blocks = c("nul", "reg")) +} +\references{ +\subsection{On core-periphery}{ + +Borgatti, Stephen P., and Martin G. Everett. 2000. +“Models of Core/Periphery Structures.” +\emph{Social Networks} 21(4):375–95. +\doi{10.1016/S0378-8733(99)00019-2} +} + +\subsection{On modularity}{ + +Newman, Mark E.J. 2006. +"Modularity and community structure in networks", +\emph{Proceedings of the National Academy of Sciences} 103(23): 8577-8696. +\doi{https://doi.org/10.1073/pnas.0601602103} + +Murata, Tsuyoshi. 2010. +"Modularity for Bipartite Networks". +In: Memon, N., Xu, J., Hicks, D., Chen, H. (eds) +\emph{Data Mining for Social Network Data. Annals of Information Systems}, Vol 12. +Springer, Boston, MA. +\doi{10.1007/978-1-4419-6287-4_7} +} + +\subsection{On generalized blockmodelling}{ + +Doreian, Patrick, Vladimir Batagelj, and Anuska Ferligoj. 2005. +\emph{Generalized Blockmodeling}. +Cambridge: Cambridge University Press. +\doi{10.1017/CBO9780511584176} +} +} +\seealso{ +Other features: +\code{\link{measure_features}} + +Other measures: +\code{\link{measure_assort_net}}, +\code{\link{measure_assort_node}}, +\code{\link{measure_breadth}}, +\code{\link{measure_broker_node}}, +\code{\link{measure_broker_tie}}, +\code{\link{measure_brokerage}}, +\code{\link{measure_central_between}}, +\code{\link{measure_central_close}}, +\code{\link{measure_central_degree}}, +\code{\link{measure_central_eigen}}, +\code{\link{measure_centralities_between}}, +\code{\link{measure_centralities_close}}, +\code{\link{measure_centralities_degree}}, +\code{\link{measure_centralities_eigen}}, +\code{\link{measure_closure}}, +\code{\link{measure_closure_node}}, +\code{\link{measure_cohesion}}, +\code{\link{measure_core}}, +\code{\link{measure_diffusion_infection}}, +\code{\link{measure_diffusion_net}}, +\code{\link{measure_diffusion_node}}, +\code{\link{measure_diverse_net}}, +\code{\link{measure_diverse_node}}, +\code{\link{measure_features}}, +\code{\link{measure_fragmentation}}, +\code{\link{measure_hierarchy}}, +\code{\link{measure_periods}} +} +\concept{features} +\concept{measures} diff --git a/tests/testthat/test-measure_features.R b/tests/testthat/test-measure_features.R index 5fcd274..0a3981b 100644 --- a/tests/testthat/test-measure_features.R +++ b/tests/testthat/test-measure_features.R @@ -8,19 +8,6 @@ set.seed(123) # expect_error(net_balance(ison_adolescents)) # }) -test_that("net_modularity works for two mode networks", { - out <- net_by_modularity(ison_southern_women, - node_in_partition(ison_southern_women)) - expect_length(out, 1) -}) - -test_that("net_core works", { - out <- net_by_core(ison_adolescents) - expect_values(out, -0.133) - expect_values(net_by_core(ison_adolescents, method = "ident"), 6.481) - expect_values(net_by_core(ison_adolescents, method = "diff"), 6.094) -}) - test_that("net_richclub works", { out <- net_by_richclub(ison_adolescents) expect_values(out, 0.833) @@ -43,4 +30,3 @@ test_that("net_waves works", { # expect_equal(net_waves(ison_adolescents), 1) expect_values(net_by_waves(wavenet), 3) }) - diff --git a/tests/testthat/test-measure_fit.R b/tests/testthat/test-measure_fit.R new file mode 100644 index 0000000..318cb69 --- /dev/null +++ b/tests/testthat/test-measure_fit.R @@ -0,0 +1,61 @@ +test_that("net_modularity works for two mode networks", { + out <- net_by_modularity(ison_southern_women, + node_in_partition(ison_southern_women)) + expect_length(out, 1) +}) + +test_that("net_core works", { + out <- net_by_core(ison_adolescents) + expect_values(out, -0.133) + expect_values(net_by_core(ison_adolescents, method = "ident"), 6.481) + expect_values(net_by_core(ison_adolescents, method = "diff"), 6.094) +}) + +test_that("net_by_inconsistency scores a partition against ideal blocks", { + m <- node_in_structural(ison_adolescents, k = 3) + expect_s3_class(net_by_inconsistency(ison_adolescents, m), "network_measure") + # a "do not care" vocabulary can never be inconsistent + expect_equal(as.numeric(net_by_inconsistency(ison_adolescents, m, blocks = "dnc")), 0) + # a partition that exactly reproduces components fits perfectly + pf <- create_components(create_filled(6), membership = c(1, 1, 1, 2, 2, 2)) + expect_equal(as.numeric(net_by_inconsistency(pf, c(1, 1, 1, 2, 2, 2))), 0) + # permitting more ideal types can only lower the criterion + expect_lte( + as.numeric(net_by_inconsistency(ison_adolescents, m, + blocks = c("nul", "com", "reg"))), + as.numeric(net_by_inconsistency(ison_adolescents, m))) + # a fitted partition beats a random one + set.seed(1) + expect_lt(as.numeric(net_by_inconsistency(ison_adolescents, m)), + as.numeric(net_by_inconsistency(ison_adolescents, + sample(rep(1:3, length.out = 8))))) + # a generalized, per-position vocabulary is accepted + b <- matrix(list(), 2, 2) + b[[1, 1]] <- "reg"; b[[2, 2]] <- "reg" + b[[1, 2]] <- "nul"; b[[2, 1]] <- "nul" + expect_s3_class(net_by_inconsistency(ison_adolescents, + node_in_structural(ison_adolescents, k = 2), + blocks = b), "network_measure") +}) + +test_that("net_by_inconsistency behaves as a distance, as documented", { + # bounded in [0,1] for cell-counting vocabularies, since each cell can + # contribute at most one error + for (k in 2:8) { + v <- as.numeric(net_by_inconsistency(ison_adolescents, + cut(seq_len(8), k, labels = FALSE), + blocks = c("nul", "com"))) + expect_gte(v, 0) + expect_lte(v, 1) + } + # but NOT bounded by 1 once `reg` is permitted, because line counts are + # divided by a cell count + expect_gt(as.numeric(net_by_inconsistency(ison_adolescents, seq_len(8), + blocks = "reg")), 1) + # and the vocabularies invert at the finest partition: every block is one + # cell, so trivially null-or-complete, but never regular + expect_equal(as.numeric(net_by_inconsistency(ison_adolescents, seq_len(8), + blocks = c("nul", "com"))), 0) + expect_gt(as.numeric(net_by_inconsistency(ison_adolescents, seq_len(8), + blocks = "reg")), 0) +}) From 36afdf902903cec78e4bee9e97e5d16f89e5c619 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 15:57:00 +0200 Subject: [PATCH 13/68] Added `node_in_block()` for direct blockmodelling --- NEWS.md | 2 + R/member_equivalence.R | 62 +++++++++++++ R/netrics-utils.R | 40 ++++++++ man/measure_assort_net.Rd | 1 + man/measure_assort_node.Rd | 1 + man/measure_breadth.Rd | 1 + man/measure_broker_node.Rd | 1 + man/measure_broker_tie.Rd | 1 + man/measure_brokerage.Rd | 1 + man/measure_central_between.Rd | 1 + man/measure_central_close.Rd | 85 ++++++++++++++++- man/measure_central_degree.Rd | 1 + man/measure_central_eigen.Rd | 1 + man/measure_centralities_between.Rd | 1 + man/measure_centralities_close.Rd | 1 + man/measure_centralities_degree.Rd | 1 + man/measure_centralities_eigen.Rd | 1 + man/measure_closure.Rd | 1 + man/measure_closure_node.Rd | 1 + man/measure_cohesion.Rd | 1 + man/measure_core.Rd | 1 + man/measure_diffusion_infection.Rd | 1 + man/measure_diffusion_net.Rd | 1 + man/measure_diffusion_node.Rd | 1 + man/measure_diverse_net.Rd | 1 + man/measure_diverse_node.Rd | 1 + man/measure_fragmentation.Rd | 1 + man/measure_hierarchy.Rd | 1 + man/measure_periods.Rd | 1 + man/member_equivalence.Rd | 113 +++++++++++++++++++++-- pkgdown/_pkgdown.yml | 1 + tests/testthat/test-member_equivalence.R | 23 +++++ 32 files changed, 341 insertions(+), 10 deletions(-) diff --git a/NEWS.md b/NEWS.md index 6175ea8..1c4c170 100644 --- a/NEWS.md +++ b/NEWS.md @@ -25,6 +25,8 @@ - Note existing scripts calling `node_in_regular()` will now return more correct results - Moved former behaviour of `node_in_regular()` to `node_in_motif()`, documented as capturing similarity of local embedding not role equivalence +- Added `node_in_block()` for direct blockmodelling, searching partitions + for the one that minimises `net_by_inconsistency()` ## Motifs diff --git a/R/member_equivalence.R b/R/member_equivalence.R index 50b626c..e8db2ed 100644 --- a/R/member_equivalence.R +++ b/R/member_equivalence.R @@ -202,3 +202,65 @@ node_in_automorphic <- function(.data, node_in_equivalence(.data, mat, k = k, cluster = cluster, distance = distance, Kmax = Kmax) } + +#' @rdname member_equivalence +#' @param blocks A character vector of permitted ideal block types, +#' or a list-matrix giving the permitted types per block position. +#' See [net_by_inconsistency()] for the available types. +#' @param times Integer number of search iterations. +#' By default the number of nodes times the number of positions. +#' @section Direct blockmodelling: +#' The other functions here are _indirect_: they build a similarity between +#' nodes, cluster it, and read a partition off the result. +#' `node_in_block()` is _direct_. It searches the space of partitions for +#' the one that best fits an ideal block structure, scoring each candidate +#' with [net_by_inconsistency()] and keeping whichever is most consistent. +#' +#' The advantage is that the criterion being optimised is the one you +#' actually care about, rather than a similarity that stands in for it, +#' and that ideal types other than "null and complete" become available — +#' `blocks = c("nul", "reg")` searches directly for a regular-equivalence +#' blockmodel. +#' The cost is that the number of positions `k` must be chosen in advance, +#' and that the search is stochastic: it explores by random restarts and +#' perturbations, so repeated runs may return different partitions and a +#' longer search is more likely to find a good one. +#' Set a seed for reproducibility, and compare runs with [net_by_inconsistency()]. +#' @references +#' ## On direct blockmodelling +#' Doreian, Patrick, Vladimir Batagelj, and Anuska Ferligoj. 2005. +#' _Generalized Blockmodeling_. +#' Cambridge: Cambridge University Press. +#' \doi{10.1017/CBO9780511584176} +#' @examples +#' (nbm <- node_in_block(ison_adolescents, k = 3)) +#' net_by_inconsistency(ison_adolescents, nbm) +#' @export +node_in_block <- function(.data, k = 2L, + blocks = c("nul", "com"), + times = NULL){ + .data <- manynet::expect_nodes(.data) + if(!is.numeric(k) || k < 2) + manynet::snet_abort("`k` must be the number of positions sought, at least 2.") + n <- manynet::net_nodes(.data) + if(k > n) manynet::snet_abort("`k` cannot exceed the number of nodes.") + if(is.null(times)) times <- n * k + fitness <- function(m) as.numeric(net_by_inconsistency(.data, m, blocks = blocks)) + # begin from a random partition into k roughly equal positions + shuffled <- sample(seq.int(n)) + out <- cut(seq_along(shuffled), k, labels = FALSE)[shuffled] + fit <- fitness(out) + soln <- out + for(t in seq.int(times)){ + soln <- .weakPerturb(soln) + new_fit <- fitness(soln) + if(new_fit < fit){ + out <- soln + fit <- new_fit + } + if(t %% 10) soln <- .strongPerturb(soln) + } + out <- make_node_member(out, .data) + attr(out, "k") <- k + out +} diff --git a/R/netrics-utils.R b/R/netrics-utils.R index dd51796..a4da646 100644 --- a/R/netrics-utils.R +++ b/R/netrics-utils.R @@ -52,4 +52,44 @@ seq_nodes <- function(.data){ } } + +# Local-search perturbations over a membership vector, shared by the +# random-restart searches in `node_in_roulette()` and `node_in_block()`. +# A weak perturbation makes one small move; a strong one makes enough moves +# to escape a local optimum. +.weakPerturb <- function(soln){ + gsizes <- table(soln) + evens <- all(gsizes == max(gsizes)) + if(evens){ + soln <- .swapMove(soln) + } else { + if(stats::runif(1)<0.5) soln <- .swapMove(soln) else + soln <- .oneMove(soln) + } + soln +} + +.swapMove <- function(soln){ + from <- sample(seq.int(length(soln)), 1) + to <- sample(which(soln != soln[from]), 1) + soln[c(to,from)] <- soln[c(from,to)] + soln +} + +.oneMove <- function(soln){ + gsizes <- table(soln) + maxg <- which(gsizes == max(gsizes)) + from <- sample(which(soln %in% maxg), 1) + soln[from] <- sample(which(gsizes != max(gsizes)), 1) + soln +} + +.strongPerturb <- function(soln, strength = 1){ + times <- ceiling(strength * length(soln)/max(soln)) + for (t in seq.int(times)){ + soln <- .weakPerturb(soln) + } + soln +} + # nocov end \ No newline at end of file diff --git a/man/measure_assort_net.Rd b/man/measure_assort_net.Rd index 47ffa9d..66e308c 100644 --- a/man/measure_assort_net.Rd +++ b/man/measure_assort_net.Rd @@ -168,6 +168,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_assort_node.Rd b/man/measure_assort_node.Rd index 8eb2fd7..e59f485 100644 --- a/man/measure_assort_node.Rd +++ b/man/measure_assort_node.Rd @@ -108,6 +108,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_breadth.Rd b/man/measure_breadth.Rd index 0405d79..d3a7b48 100644 --- a/man/measure_breadth.Rd +++ b/man/measure_breadth.Rd @@ -63,6 +63,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_broker_node.Rd b/man/measure_broker_node.Rd index 78d1f96..67a8928 100644 --- a/man/measure_broker_node.Rd +++ b/man/measure_broker_node.Rd @@ -141,6 +141,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_broker_tie.Rd b/man/measure_broker_tie.Rd index 4e8d591..37c3933 100644 --- a/man/measure_broker_tie.Rd +++ b/man/measure_broker_tie.Rd @@ -55,6 +55,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_brokerage.Rd b/man/measure_brokerage.Rd index 5083142..f6ce506 100644 --- a/man/measure_brokerage.Rd +++ b/man/measure_brokerage.Rd @@ -74,6 +74,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_central_between.Rd b/man/measure_central_between.Rd index a974828..017ed9e 100644 --- a/man/measure_central_between.Rd +++ b/man/measure_central_between.Rd @@ -169,6 +169,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_central_close.Rd b/man/measure_central_close.Rd index 5bfc6d5..7cbd254 100644 --- a/man/measure_central_close.Rd +++ b/man/measure_central_close.Rd @@ -5,6 +5,8 @@ \alias{node_by_closeness} \alias{node_by_harmonic} \alias{node_by_reach} +\alias{node_by_decay} +\alias{node_by_integration} \alias{node_by_information} \alias{node_by_eccentricity} \alias{node_by_distance} @@ -18,6 +20,15 @@ node_by_harmonic(.data, normalized = TRUE, cutoff = -1) node_by_reach(.data, normalized = TRUE, cutoff = 2) +node_by_decay( + .data, + normalized = TRUE, + decay = 0.5, + direction = c("out", "in") +) + +node_by_integration(.data, normalized = TRUE, direction = c("in", "out")) + node_by_information(.data, normalized = TRUE) node_by_eccentricity(.data, normalized = TRUE) @@ -29,9 +40,9 @@ node_by_vitality(.data, normalized = TRUE) node_by_randomwalk(.data, normalized = TRUE) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, @@ -42,7 +53,17 @@ By default TRUE.} “in” on incoming ties, and "all" on either/the sum of the two. By default "all".} -\item{cutoff}{Maximum path length to use during calculations.} +\item{cutoff}{Integer scalar, the maximum path length considered. +Paths longer than this are ignored, which restricts the measure to a +node's local neighbourhood. +Where a measure is defined over all paths by default, +a negative value or \code{NULL} imposes no limit.} + +\item{decay}{A proportion between 0 and 1 indicating how quickly +the contribution of more distant nodes decays. +By default 0.5, so that each additional step halves a node's contribution. +As \code{decay} approaches 0 this approaches degree centrality, +and as it approaches 1 this approaches the size of the node's component.} \item{from, to}{Index or name of a node to calculate distances from or to.} } @@ -63,6 +84,10 @@ centrality, which is thought to behave better than reach centrality for disconnected networks. \item \code{node_by_reach()} measures nodes' reach centrality, or how many nodes they can reach within \emph{k} steps. +\item \code{node_by_decay()} measures nodes' decay centrality, +a distance-weighted generalisation of reach centrality. +\item \code{node_by_integration()} measures nodes' integration or radiality, +which weights alters by how close they are rather than counting them. \item \code{node_by_information()} measures nodes' information centrality or current-flow closeness centrality. \item \code{node_by_eccentricity()} measures nodes' eccentricity or maximum distance @@ -119,6 +144,37 @@ Note that if \eqn{k = 1} (i.e. cutoff = 1), then this returns the node's degree. At higher cutoff reach centrality returns the size of the node's component. } +\section{Decay centrality}{ + +Where reach centrality counts how many others are within a fixed number of +steps, decay centrality weights every reachable other by how far away they +are, so that nearer nodes count for more: +\deqn{C_D(i) = \sum_{j, j \neq i} \delta^{d(i,j)-1}} +where \eqn{\delta} is the decay parameter and unreachable nodes contribute +nothing. This avoids having to choose a single cutoff, since the +contribution of distant nodes tapers off smoothly rather than being +truncated. Normalization is by \eqn{N-1}, the score achieved when a node +is adjacent to all others. +} + +\section{Integration and radiality}{ + +Integration centrality, also known as radiality, inverts the usual farness +logic: instead of summing distances, it sums how much \emph{closer} than the +network's diameter each other node is: +\deqn{C_I(i) = \sum_{j, j \neq i} (\Delta - d(i,j) + 1)} +where \eqn{\Delta} is the maximum finite distance in the network. +Nodes that are near to many others therefore score highly, +while unreachable pairs contribute nothing. +Normalization is by \eqn{(N-1)\Delta}. + +Valente and Foreman distinguish the two directions: +\emph{integration} is calculated on incoming ties, capturing how well a node is +reached by others, whereas \emph{radiality} is calculated on outgoing ties, +capturing how well a node reaches others. +Use \code{direction} to choose; in undirected networks they coincide. +} + \section{Information centrality}{ Information centrality, also known as current-flow centrality, @@ -177,6 +233,8 @@ where \eqn{H_{ji}} is the hitting time from node \eqn{j} to node \eqn{i}. \examples{ node_by_closeness(ison_southern_women) node_by_reach(ison_adolescents) +node_by_decay(ison_adolescents) +node_by_integration(ison_adolescents) } \references{ \subsection{On closeness centrality}{ @@ -211,6 +269,22 @@ Borgatti, Stephen P., Martin G. Everett, and J.C. Johnson. 2013. London: SAGE Publications Limited. } +\subsection{On decay centrality}{ + +Jackson, Matthew O. 2008. +\emph{Social and Economic Networks}. +Princeton: Princeton University Press. +} + +\subsection{On integration and radiality}{ + +Valente, Thomas W., and Robert K. Foreman. 1998. +"Integration and radiality: Measuring the extent of an individual's +connectedness and reachability in a network". +\emph{Social Networks} 20(1): 89-105. +\doi{10.1016/S0378-8733(97)00007-5} +} + \subsection{On information centrality}{ Stephenson, Karen, and Marvin Zelen. 1989. @@ -292,6 +366,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} @@ -322,6 +397,8 @@ Other nodal: \code{\link{member_diffusion}}, \code{\link{member_equivalence}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/man/measure_central_degree.Rd b/man/measure_central_degree.Rd index da75251..d679978 100644 --- a/man/measure_central_degree.Rd +++ b/man/measure_central_degree.Rd @@ -202,6 +202,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_central_eigen.Rd b/man/measure_central_eigen.Rd index e1c42f2..b8a353c 100644 --- a/man/measure_central_eigen.Rd +++ b/man/measure_central_eigen.Rd @@ -248,6 +248,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_centralities_between.Rd b/man/measure_centralities_between.Rd index 140b9ee..7181569 100644 --- a/man/measure_centralities_between.Rd +++ b/man/measure_centralities_between.Rd @@ -79,6 +79,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_centralities_close.Rd b/man/measure_centralities_close.Rd index 7c41115..8af0996 100644 --- a/man/measure_centralities_close.Rd +++ b/man/measure_centralities_close.Rd @@ -80,6 +80,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_centralities_degree.Rd b/man/measure_centralities_degree.Rd index e5bab4b..1b287e7 100644 --- a/man/measure_centralities_degree.Rd +++ b/man/measure_centralities_degree.Rd @@ -79,6 +79,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_centralities_eigen.Rd b/man/measure_centralities_eigen.Rd index 043017f..f8c547f 100644 --- a/man/measure_centralities_eigen.Rd +++ b/man/measure_centralities_eigen.Rd @@ -79,6 +79,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_closure.Rd b/man/measure_closure.Rd index a3b611a..bf168fb 100644 --- a/man/measure_closure.Rd +++ b/man/measure_closure.Rd @@ -133,6 +133,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_closure_node.Rd b/man/measure_closure_node.Rd index 94e5da1..0659dee 100644 --- a/man/measure_closure_node.Rd +++ b/man/measure_closure_node.Rd @@ -70,6 +70,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_cohesion.Rd b/man/measure_cohesion.Rd index 9e9a250..8153355 100644 --- a/man/measure_cohesion.Rd +++ b/man/measure_cohesion.Rd @@ -121,6 +121,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_core.Rd b/man/measure_core.Rd index e70c978..96932b4 100644 --- a/man/measure_core.Rd +++ b/man/measure_core.Rd @@ -85,6 +85,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_diffusion_infection.Rd b/man/measure_diffusion_infection.Rd index 4fbedfc..435271e 100644 --- a/man/measure_diffusion_infection.Rd +++ b/man/measure_diffusion_infection.Rd @@ -84,6 +84,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_diffusion_net.Rd b/man/measure_diffusion_net.Rd index 3dda546..fb7ec82 100644 --- a/man/measure_diffusion_net.Rd +++ b/man/measure_diffusion_net.Rd @@ -192,6 +192,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_diffusion_node.Rd b/man/measure_diffusion_node.Rd index 8882fac..f32cf79 100644 --- a/man/measure_diffusion_node.Rd +++ b/man/measure_diffusion_node.Rd @@ -153,6 +153,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_diverse_net.Rd b/man/measure_diverse_net.Rd index c6c11e6..c7d38f6 100644 --- a/man/measure_diverse_net.Rd +++ b/man/measure_diverse_net.Rd @@ -168,6 +168,7 @@ Other measures: \code{\link{measure_diffusion_node}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_diverse_node.Rd b/man/measure_diverse_node.Rd index 81ef495..bfae8b8 100644 --- a/man/measure_diverse_node.Rd +++ b/man/measure_diverse_node.Rd @@ -83,6 +83,7 @@ Other measures: \code{\link{measure_diffusion_node}}, \code{\link{measure_diverse_net}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} diff --git a/man/measure_fragmentation.Rd b/man/measure_fragmentation.Rd index b551501..4318f69 100644 --- a/man/measure_fragmentation.Rd +++ b/man/measure_fragmentation.Rd @@ -88,6 +88,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_hierarchy}}, \code{\link{measure_periods}} } diff --git a/man/measure_hierarchy.Rd b/man/measure_hierarchy.Rd index 1b93f2d..de7f2a0 100644 --- a/man/measure_hierarchy.Rd +++ b/man/measure_hierarchy.Rd @@ -78,6 +78,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_periods}} diff --git a/man/measure_periods.Rd b/man/measure_periods.Rd index 56deb70..20ba664 100644 --- a/man/measure_periods.Rd +++ b/man/measure_periods.Rd @@ -47,6 +47,7 @@ Other measures: \code{\link{measure_diverse_net}}, \code{\link{measure_diverse_node}}, \code{\link{measure_features}}, +\code{\link{measure_fit}}, \code{\link{measure_fragmentation}}, \code{\link{measure_hierarchy}} } diff --git a/man/member_equivalence.Rd b/man/member_equivalence.Rd index 166bce5..5f811f7 100644 --- a/man/member_equivalence.Rd +++ b/man/member_equivalence.Rd @@ -5,7 +5,9 @@ \alias{node_in_equivalence} \alias{node_in_structural} \alias{node_in_regular} +\alias{node_in_motif} \alias{node_in_automorphic} +\alias{node_in_block} \title{Memberships in equivalent classes} \source{ \url{https://github.com/aslez/concoR} @@ -29,6 +31,16 @@ node_in_structural( ) node_in_regular( + .data, + k = c("silhouette", "elbow", "strict"), + cluster = c("hierarchical", "concor", "cosine"), + distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), + Kmax = 8L, + regularity = c("rolesim", "rege"), + beta = 0.15 +) + +node_in_motif( .data, k = c("silhouette", "elbow", "strict"), cluster = c("hierarchical", "concor", "cosine"), @@ -43,11 +55,13 @@ node_in_automorphic( distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), Kmax = 8L ) + +node_in_block(.data, k = 2L, blocks = c("nul", "com"), times = NULL) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{motif}{A matrix returned by a \verb{node_x_*()} function.} @@ -75,6 +89,22 @@ Fewer, identifiable letters, e.g. \code{"e"} for Euclidean, is sufficient.} \item{Kmax}{Integer indicating the maximum number of (k) clusters to evaluate. Ignored when \code{k = "strict"} or a discrete number is given for \code{k}.} + +\item{regularity}{Character string indicating which algorithm should be +used to calculate how regularly equivalent nodes are. +By default \code{"rolesim"}; \code{"rege"} is also available. +Fewer, identifiable letters, e.g. \code{"ro"} for RoleSim, is sufficient. +See \code{\link[=regularity_rolesim]{regularity_rolesim()}} and \code{\link[=regularity_rege]{regularity_rege()}} for how they differ.} + +\item{beta}{A decay parameter between 0 and 1 passed to \code{\link[=regularity_rolesim]{regularity_rolesim()}}, +controlling how much weight is given to the recursive component.} + +\item{blocks}{A character vector of permitted ideal block types, +or a list-matrix giving the permitted types per block position. +See \code{\link[=net_by_inconsistency]{net_by_inconsistency()}} for the available types.} + +\item{times}{Integer number of search iterations. +By default the number of nodes times the number of positions.} } \value{ A \code{node_member} character vector the length of the nodes in the network, @@ -93,23 +123,92 @@ The following functions call this function, together with an appropriate motif. \item \code{node_in_structural()} assigns nodes membership based on their having equivalent ties to the same other nodes. \item \code{node_in_regular()} assigns nodes membership based on their -having equivalent patterns of ties. +having equivalent patterns of ties to equivalent others. \item \code{node_in_automorphic()} assigns nodes membership based on their having equivalent distances to other nodes. +\item \code{node_in_motif()} assigns nodes membership based on their +participating in local structures at similar rates. } A \code{plot()} method exists for investigating the dendrogram of the hierarchical cluster and showing the returned cluster assignment. } +\section{Regular equivalence}{ + +Two nodes are regularly equivalent if each has ties to the same \emph{kinds} of +others, even where those others are not the same individuals and are not +equally numerous. A manager with three subordinates and a manager with ten +are regularly equivalent, because what makes them alike is that they both +have subordinates, not how many or which. + +The definition is recursive: nodes are equivalent if their alters are +equivalent, whose equivalence depends in turn on \emph{their} alters. +\code{node_in_regular()} therefore computes a similarity matrix by iterating +that definition to a fixed point, and then clusters it in the same way as +the other functions here. + +Note that this differs from \code{node_in_motif()}, which compares nodes on how +often they appear embedded in local structures. +Two nodes can have very similar triad profiles without being regularly equivalent, +and vice versa, since a motif census counts a node's local configurations +while regular equivalence asks who its alters are. +} + +\section{Motif equivalence}{ + +Where the other functions here compare nodes on \emph{whom} they are tied to, +\code{node_in_motif()} compares them on \emph{what kinds of local structure} they sit +in, by clustering a census of the triads (or, for two-mode networks, +tetrads) each node participates in. + +This captures similarity of local embedding rather than equivalence of +role. It is well suited to distinguishing nodes that sit in dense, +closed neighbourhoods from those that bridge open ones, +but it is not regular equivalence: see \code{node_in_regular()} for that. + +This function was called \code{node_in_regular()} prior to version 0.5.0. +} + +\section{Direct blockmodelling}{ + +The other functions here are \emph{indirect}: they build a similarity between +nodes, cluster it, and read a partition off the result. +\code{node_in_block()} is \emph{direct}. It searches the space of partitions for +the one that best fits an ideal block structure, scoring each candidate +with \code{\link[=net_by_inconsistency]{net_by_inconsistency()}} and keeping whichever is most consistent. + +The advantage is that the criterion being optimised is the one you +actually care about, rather than a similarity that stands in for it, +and that ideal types other than "null and complete" become available — +\code{blocks = c("nul", "reg")} searches directly for a regular-equivalence +blockmodel. +The cost is that the number of positions \code{k} must be chosen in advance, +and that the search is stochastic: it explores by random restarts and +perturbations, so repeated runs may return different partitions and a +longer search is more likely to find a good one. +Set a seed for reproducibility, and compare runs with \code{\link[=net_by_inconsistency]{net_by_inconsistency()}}. +} + \examples{ (nse <- node_in_structural(ison_algebra)) -(nre <- node_in_regular(ison_southern_women, - cluster = "concor")) +(nre <- node_in_regular(ison_southern_women)) +(nme <- node_in_motif(ison_southern_women, cluster = "concor")) if(require("sna", quietly = TRUE)){ (nae <- node_in_automorphic(ison_southern_women, k = "elbow")) } +(nbm <- node_in_block(ison_adolescents, k = 3)) +net_by_inconsistency(ison_adolescents, nbm) +} +\references{ +\subsection{On direct blockmodelling}{ + +Doreian, Patrick, Vladimir Batagelj, and Anuska Ferligoj. 2005. +\emph{Generalized Blockmodeling}. +Cambridge: Cambridge University Press. +\doi{10.1017/CBO9780511584176} +} } \seealso{ Other memberships: @@ -148,6 +247,8 @@ Other nodal: \code{\link{member_core}}, \code{\link{member_diffusion}}, \code{\link{motif_brokerage_node}}, +\code{\link{motif_clique}}, +\code{\link{motif_composition}}, \code{\link{motif_exposure}}, \code{\link{motif_node}}, \code{\link{motif_path}} diff --git a/pkgdown/_pkgdown.yml b/pkgdown/_pkgdown.yml index fed2dcf..0bf707f 100644 --- a/pkgdown/_pkgdown.yml +++ b/pkgdown/_pkgdown.yml @@ -113,6 +113,7 @@ reference: - measure_features - measure_fragmentation - measure_core + - measure_fit - subtitle: "Heterogeneity" contents: - starts_with("measure_assort") diff --git a/tests/testthat/test-member_equivalence.R b/tests/testthat/test-member_equivalence.R index 6278502..4b63c64 100644 --- a/tests/testthat/test-member_equivalence.R +++ b/tests/testthat/test-member_equivalence.R @@ -72,3 +72,26 @@ test_that("node_in_regular uses recursive similarity, not a census", { expect_equal(node_in_regular(ison_adolescents, "strict"), node_in_regular(ison_adolescents, k = "strict")) }) + +test_that("node_in_block searches for a fitting partition", { + set.seed(123) + res <- node_in_block(ison_adolescents, k = 3) + expect_s3_class(res, "node_member") + expect_length(res, manynet::net_nodes(ison_adolescents)) + expect_lte(length(unique(res)), 3) + # the search should do at least as well as a random partition + set.seed(1) + expect_lte(as.numeric(net_by_inconsistency(ison_adolescents, res)), + as.numeric(net_by_inconsistency(ison_adolescents, + sample(rep(1:3, length.out = 8))))) + # it optimises whichever vocabulary it is given + set.seed(9) + reg <- node_in_block(ison_adolescents, k = 3, blocks = c("nul", "reg")) + expect_lte(as.numeric(net_by_inconsistency(ison_adolescents, reg, + blocks = c("nul", "reg"))), + as.numeric(net_by_inconsistency(ison_adolescents, + node_in_structural(ison_adolescents, k = 3), + blocks = c("nul", "reg")))) + expect_error(node_in_block(ison_adolescents, k = 1)) + expect_error(node_in_block(ison_adolescents, k = 99)) +}) From 4ee40e6b39d1f3333d802640b903e44bed8fef21 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 16:00:54 +0200 Subject: [PATCH 14/68] Updated position tutorial to use `node_in_regular()` for regular equivalence rather than the triad census --- .github/CONTRIBUTING.md | 38 ++++++++++ NAMESPACE | 2 + NEWS.md | 4 + inst/tutorials/netrics3/position.Rmd | 107 +++++++++++++++++++++++++-- vignettes/articles/position.Rmd | 107 +++++++++++++++++++++++++-- 5 files changed, 246 insertions(+), 12 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4616fe9..650f937 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -86,6 +86,44 @@ Functions are grouped into four families by naming pattern, each with dedicated When adding a new analytic function, pick the family that matches its semantics and follow the existing naming scheme exactly. This predictability is a stated project goal. +### Method helper naming + +Besides the four analytic families, some functions take a **character argument that selects a method**. +These are not S3 methods; dispatch is by `switch()`. The rule is that the function called is named **`_`**, so `k = "elbow"` calls `k_elbow()`, `cluster = "concor"` calls `cluster_concor()`, and `regularity = "rege"` calls `regularity_rege()`. +Users can therefore find the implementation, and its documentation, from the argument alone. + +**Each family is named for what it returns** — not for the concept it serves, and not for the function that calls it: + +| Rd name | Returns | Functions | Argument | +|---|---|---|---| +| `method_kselect` | an integer, the number of clusters | `k_*` | `k =` | +| `method_cluster` | an `hclust` clustering object | `cluster_*` | `cluster =` | +| `method_regularity` | a node-by-node similarity matrix | `regularity_*` | `regularity =` | + +Apply that test when naming a new family. For example, `equivalence_*` would be the wrong name for `regularity_*`, even though those methods are only ever called from `node_in_regular()`: they return a *similarity*, which `cluster_*()` only later partitions into an equivalence. Naming the step for the pipeline's eventual output rather than its own return value breaks the rule. + +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. + +### Naming within the membership family + +`node_in_*()` names divide into two kinds, and new functions should follow whichever fits: + +- **Group-nouns** name the grouping itself, and are the generic entry point where there is one: `node_in_community()` tries every applicable algorithm and returns the highest-modularity partition; `node_in_component()` sits above `node_in_strong()`/`node_in_weak()`. Also `node_in_core()`, `node_in_block()`. +- **Algorithm names** name one specific method: `node_in_louvain()`, `node_in_leiden()`, `node_in_walktrap()`, `node_in_infomap()`, `node_in_spinglass()`, `node_in_roulette()`, `node_in_partition()` (Kernighan–Lin). + +Two rules about number: + +- **Number follows level, not stem.** `node_in_*()` is singular, because a node belongs to one group; `net_by_*()` takes the plural when the measure concerns all of them. Hence `node_in_component()` with `net_by_components()`. Do not "correct" one of a pair to match the other — the mismatch is the convention. (Note that `net_by_*` names ending in *s* are not all plurals: `betweenness`, `compactness`, `richness` and `toughness` are abstract nouns. The real plurals are `components`, `factions` and `waves`.) +- **The rule is about number, not about the stem.** It settles whether to write `component` or `components` at a given level; it does not establish that a stem is the right one. `net_by_components()` returns a count *of the components*, a fact about the things named — but a measure of, say, how far a partition departs from an ideal structure is not a fact about those groups in that way, and should be named for the quantity it returns instead. That is why the blockmodelling criterion is `net_by_inconsistency()` rather than `net_by_blocks()`, even though its partitions come from `node_in_block()`. +- **Never plural in `node_in_*()`**, both because of the rule above and because `to_*s()` already means "returns a list" in `{manynet}` (`to_components()`, `to_egos()`). + +Finally, avoid words that imply another stocnet package's remit. `{netrics}` is descriptive; statistical modelling and testing belong to `{migraph}`. This is why the direct blockmodelling search is `node_in_block()` rather than `node_in_blockmodel()`, even though "blockmodel" is the literature's term — prose and `@section` headings should still say blockmodelling, since it is only the exported name that signals remit. + ### Function body convention Functions consistently: diff --git a/NAMESPACE b/NAMESPACE index 2c63e23..d7e8a02 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -37,6 +37,7 @@ export(net_by_harmonic) export(net_by_heterophily) export(net_by_homophily) export(net_by_immunity) +export(net_by_inconsistency) export(net_by_indegree) export(net_by_independence) export(net_by_infection_complete) @@ -122,6 +123,7 @@ export(node_by_vitality) export(node_in_adopter) export(node_in_automorphic) export(node_in_betweenness) +export(node_in_block) export(node_in_brokering) export(node_in_community) export(node_in_component) diff --git a/NEWS.md b/NEWS.md index 1c4c170..74c07dd 100644 --- a/NEWS.md +++ b/NEWS.md @@ -52,6 +52,10 @@ or its spread across layers in a multiplex network - Added `regularity_rolesim()` and `regularity_rege()`, recursive role similarity methods - Note `regularity_rege()` degenerate on unweighted connected ones, where it warns + +## Tutorials + +- Updated position tutorial to use `node_in_regular()` for regular equivalence rather than the triad census # netrics 0.4.1 diff --git a/inst/tutorials/netrics3/position.Rmd b/inst/tutorials/netrics3/position.Rmd index 3880bd0..79300f7 100644 --- a/inst/tutorials/netrics3/position.Rmd +++ b/inst/tutorials/netrics3/position.Rmd @@ -156,6 +156,7 @@ By the end of this tutorial, you should be able to: - [ ]   Distinguish structural, regular, and automorphic equivalence, and compute each with `node_in_structural()`, `node_in_regular()`, and `node_in_automorphic()` - [ ]   Partition a network into equivalent classes, and understand the census, clustering, and _k_-selection choices behind it - [ ]   Read a dendrogram and a blockmodel, and justify a choice of _k_ +- [ ]   Score how well a partition fits with `net_by_inconsistency()`, and search for one directly with `node_in_block()` - [ ]   Contract a network into a reduced graph of positions with `to_blocks()` **Choose your own data**: The worked examples below use `ison_algebra`, @@ -1021,7 +1022,7 @@ However, the more generic `node_in_equivalence()` is available and can be used with whichever census (`node_x_*()` output) is desired. Feel free to explore using some of the other censuses available in `{netrics}`, though some common ones are already used in the other equivalence convenience functions, -e.g. `node_x_triad()` in `node_in_regular()` +e.g. `node_x_triad()` in `node_in_motif()` and `node_x_path()` in `node_in_automorphic()` — functions we will actually use later in this tutorial. @@ -1399,6 +1400,84 @@ the adjacency matrix sorted into blocks whose density (or emptiness) characterises how each class relates to each other class. ::: +### Evaluating a blockmodel {#evaluating-a-blockmodel} + +Reading a blockmodel tells you what a partition _says_. +It does not tell you how _well_ it says it. +Two different partitions of the same network will both produce a blockmodel +you can describe in words, and you need some way to prefer one over the other. + +The idea is to ask how far each block departs from being _ideal_. +The two ideal types you have implicitly been using are the null block, +which should contain no ties at all, and the complete block, +which should contain every possible tie. +Counting the ties you would have to add or remove to make every block one or +the other gives a single number: how much the partition has to lie about the +network in order to describe it. `net_by_inconsistency()` reports exactly that, +normalised by the number of cells, so **0 is a perfect fit**. +Note that it therefore runs the opposite way to most measures — +it is a distance from the ideal, so lower is better. + +**Compare the structural-equivalence partition of `alge` against a coarser one +and against a random partition.** + +```{r blockfit, exercise = TRUE, exercise.setup = "varyclust"} +net_by_inconsistency(alge, node_in_structural(alge)) + +# a coarser partition has to lump dissimilar nodes together +net_by_inconsistency(alge, node_in_structural(alge, k = 2)) + +# and an arbitrary partition should do worse than a fitted one +net_by_inconsistency(alge, sample(rep(1:4, length.out = net_nodes(alge)))) +``` + +Notice that finer partitions always fit at least as well as coarser ones, +which is why the criterion cannot by itself choose _k_ for you — +in the limit, giving every node its own class fits perfectly and explains +nothing. It compares partitions _at a given k_. + +Ideal blocks other than null and complete are available through the `blocks` +argument. A **regular** block, for instance, only asks that every row and +every column contain at least one tie, rather than all of them — +which is exactly the blockmodel counterpart of regular equivalence. + +```{r blockreg, exercise = TRUE, exercise.setup = "varyclust"} +# structural blockmodelling: blocks must be empty or full +net_by_inconsistency(alge, node_in_structural(alge, k = 3)) + +# regular blockmodelling: blocks must be empty or have every row and column +# represented, which is a much easier standard to meet +net_by_inconsistency(alge, node_in_structural(alge, k = 3), blocks = c("nul", "reg")) +``` + +Since permitting more ideal types can only lower the criterion, +compare partitions only when you have given each the same vocabulary. + +Finally, once you can score a partition you can search for a good one directly, +rather than clustering a similarity matrix and hoping the result fits. +`node_in_block()` does this: it tries partitions, keeps whichever is most +consistent, and returns it. + +**Search for a three-position blockmodel of `alge` and compare it to the +structural-equivalence solution.** + +```{r blocksearch, exercise = TRUE, exercise.setup = "varyclust"} +set.seed(123) +nbm <- node_in_block(alge, k = 3) +net_by_inconsistency(alge, nbm) +net_by_inconsistency(alge, node_in_structural(alge, k = 3)) +``` + +Because the search is stochastic, running it again may give a different answer, +so set a seed and compare runs with `net_by_inconsistency()`. + +::: {.callout} +**In brief**: `net_by_inconsistency()` scores how far +a partition's blocks are from ideal (0 is perfect, lower is better), with the +ideal types set by `blocks`; `node_in_block()` searches directly for the +partition that minimises it. +::: + ## Reduced graphs On this page: @@ -1511,9 +1590,21 @@ This is the most permissive of the three definitions, and usually the closest to the everyday idea of a social "role": all teachers relate to _some_ students, all students to _some_ teacher, regardless of which particular ones. -Under the hood, `node_in_regular()` uses a triad census (`node_x_triad()`) -rather than the tie census, because it cares about the shape of local -neighbourhoods rather than their exact membership. +Under the hood, `node_in_regular()` works differently from the census-based +functions you have seen so far, because the definition is _recursive_: +two nodes are equivalent if their alters are equivalent, +whose equivalence depends in turn on _their_ alters. +So instead of building a census once and clustering it, +it starts by assuming every node is equivalent to every other and then +repeatedly revises those similarities until they settle, +by checking how well each node's alters can be paired up with the other's. +The resulting similarity matrix is then clustered exactly as before. + +Two algorithms are available via the `regularity` argument: +`"rolesim"` (the default) pairs each node's alters one-to-one, +so that two nodes match only if their neighbourhoods line up as wholes; +`"rege"` lets the same alter be matched more than once, which is more +permissive, and is what UCINET computes. **Compute the regular-equivalence classes for `alge`, plot the dendrogram, and colour the graph by class.** @@ -1608,7 +1699,7 @@ question("You want to identify which employees play the 'regional manager' role ::: {.callout} **In brief**: `node_in_structural()` (same partners, via a -tie census), `node_in_regular()` (same pattern / role, via a triad census), +tie census), `node_in_regular()` (same pattern / role, via a recursive similarity), and `node_in_automorphic()` (interchangeable, via a path census) are three lenses on position, from strictest to loosest in theory — though each chooses its own _k_, so their class counts won't nest neatly in @@ -1681,10 +1772,14 @@ Along the way, you have learned to use these functions: | `node_is_min()` | flags the node(s) with the minimum score, for highlighting | | `node_in_structural()` | structurally equivalent classes (same tie partners; `cluster`, `distance`, `k`, `range` options) | | `node_in_regular()`, `node_in_automorphic()` | regularly and automorphically equivalent classes (same role; interchangeable) | -| `node_x_tie()`, `node_x_triad()`, `node_x_path()` | the tie, triad, and path censuses behind the three equivalences | +| `node_in_motif()` | classes of similar local embedding, from a triad or tetrad census | +| `node_x_tie()`, `node_x_triad()`, `node_x_path()` | the tie, triad, and path censuses behind the census-based equivalences | +| `regularity_rolesim()`, `regularity_rege()` | the recursive similarity matrices behind `node_in_regular()` | | `node_in_equivalence()` | generic equivalence classes from any `node_x_*()` census | | `plot()` on a membership vector | draws the dendrogram and its cut-point | | `plot(as_matrix(net), membership = )` | draws the blockmodel: the matrix sorted into blocks | +| `net_by_inconsistency()` | scores how far a partition's blocks are from ideal (0 is perfect); `blocks` sets which ideals | +| `node_in_block()` | searches directly for the partition that best fits an ideal block structure | | `summary(census, membership = )` | averages each class's census profile | | `to_blocks()` | contracts a network into a reduced graph of positions | | `graphr(..., node_color = , node_size = )` | maps memberships or measures onto the graph | diff --git a/vignettes/articles/position.Rmd b/vignettes/articles/position.Rmd index ce45fef..5ca675b 100644 --- a/vignettes/articles/position.Rmd +++ b/vignettes/articles/position.Rmd @@ -149,6 +149,7 @@ By the end of this tutorial, you should be able to: - [ ]   Distinguish structural, regular, and automorphic equivalence, and compute each with `node_in_structural()`, `node_in_regular()`, and `node_in_automorphic()` - [ ]   Partition a network into equivalent classes, and understand the census, clustering, and _k_-selection choices behind it - [ ]   Read a dendrogram and a blockmodel, and justify a choice of _k_ +- [ ]   Score how well a partition fits with `net_by_inconsistency()`, and search for one directly with `node_in_block()` - [ ]   Contract a network into a reduced graph of positions with `to_blocks()` **Choose your own data**: The worked examples below use `ison_algebra`, @@ -722,7 +723,7 @@ However, the more generic `node_in_equivalence()` is available and can be used with whichever census (`node_x_*()` output) is desired. Feel free to explore using some of the other censuses available in `{netrics}`, though some common ones are already used in the other equivalence convenience functions, -e.g. `node_x_triad()` in `node_in_regular()` +e.g. `node_x_triad()` in `node_in_motif()` and `node_x_path()` in `node_in_automorphic()` — functions we will actually use later in this tutorial. @@ -1025,6 +1026,84 @@ the adjacency matrix sorted into blocks whose density (or emptiness) characterises how each class relates to each other class. ::: +### Evaluating a blockmodel {#evaluating-a-blockmodel} + +Reading a blockmodel tells you what a partition _says_. +It does not tell you how _well_ it says it. +Two different partitions of the same network will both produce a blockmodel +you can describe in words, and you need some way to prefer one over the other. + +The idea is to ask how far each block departs from being _ideal_. +The two ideal types you have implicitly been using are the null block, +which should contain no ties at all, and the complete block, +which should contain every possible tie. +Counting the ties you would have to add or remove to make every block one or +the other gives a single number: how much the partition has to lie about the +network in order to describe it. `net_by_inconsistency()` reports exactly that, +normalised by the number of cells, so **0 is a perfect fit**. +Note that it therefore runs the opposite way to most measures — +it is a distance from the ideal, so lower is better. + +**Compare the structural-equivalence partition of `alge` against a coarser one +and against a random partition.** + +```{r blockfit} +net_by_inconsistency(alge, node_in_structural(alge)) + +# a coarser partition has to lump dissimilar nodes together +net_by_inconsistency(alge, node_in_structural(alge, k = 2)) + +# and an arbitrary partition should do worse than a fitted one +net_by_inconsistency(alge, sample(rep(1:4, length.out = net_nodes(alge)))) +``` + +Notice that finer partitions always fit at least as well as coarser ones, +which is why the criterion cannot by itself choose _k_ for you — +in the limit, giving every node its own class fits perfectly and explains +nothing. It compares partitions _at a given k_. + +Ideal blocks other than null and complete are available through the `blocks` +argument. A **regular** block, for instance, only asks that every row and +every column contain at least one tie, rather than all of them — +which is exactly the blockmodel counterpart of regular equivalence. + +```{r blockreg} +# structural blockmodelling: blocks must be empty or full +net_by_inconsistency(alge, node_in_structural(alge, k = 3)) + +# regular blockmodelling: blocks must be empty or have every row and column +# represented, which is a much easier standard to meet +net_by_inconsistency(alge, node_in_structural(alge, k = 3), blocks = c("nul", "reg")) +``` + +Since permitting more ideal types can only lower the criterion, +compare partitions only when you have given each the same vocabulary. + +Finally, once you can score a partition you can search for a good one directly, +rather than clustering a similarity matrix and hoping the result fits. +`node_in_block()` does this: it tries partitions, keeps whichever is most +consistent, and returns it. + +**Search for a three-position blockmodel of `alge` and compare it to the +structural-equivalence solution.** + +```{r blocksearch} +set.seed(123) +nbm <- node_in_block(alge, k = 3) +net_by_inconsistency(alge, nbm) +net_by_inconsistency(alge, node_in_structural(alge, k = 3)) +``` + +Because the search is stochastic, running it again may give a different answer, +so set a seed and compare runs with `net_by_inconsistency()`. + +::: {.callout} +**In brief**: `net_by_inconsistency()` scores how far +a partition's blocks are from ideal (0 is perfect, lower is better), with the +ideal types set by `blocks`; `node_in_block()` searches directly for the +partition that minimises it. +::: + ## Reduced graphs On this page: @@ -1127,9 +1206,21 @@ This is the most permissive of the three definitions, and usually the closest to the everyday idea of a social "role": all teachers relate to _some_ students, all students to _some_ teacher, regardless of which particular ones. -Under the hood, `node_in_regular()` uses a triad census (`node_x_triad()`) -rather than the tie census, because it cares about the shape of local -neighbourhoods rather than their exact membership. +Under the hood, `node_in_regular()` works differently from the census-based +functions you have seen so far, because the definition is _recursive_: +two nodes are equivalent if their alters are equivalent, +whose equivalence depends in turn on _their_ alters. +So instead of building a census once and clustering it, +it starts by assuming every node is equivalent to every other and then +repeatedly revises those similarities until they settle, +by checking how well each node's alters can be paired up with the other's. +The resulting similarity matrix is then clustered exactly as before. + +Two algorithms are available via the `regularity` argument: +`"rolesim"` (the default) pairs each node's alters one-to-one, +so that two nodes match only if their neighbourhoods line up as wholes; +`"rege"` lets the same alter be matched more than once, which is more +permissive, and is what UCINET computes. **Compute the regular-equivalence classes for `alge`, plot the dendrogram, and colour the graph by class.** @@ -1214,7 +1305,7 @@ or "who is structurally interchangeable?" (automorphic). ::: {.callout} **In brief**: `node_in_structural()` (same partners, via a -tie census), `node_in_regular()` (same pattern / role, via a triad census), +tie census), `node_in_regular()` (same pattern / role, via a recursive similarity), and `node_in_automorphic()` (interchangeable, via a path census) are three lenses on position, from strictest to loosest in theory — though each chooses its own _k_, so their class counts won't nest neatly in @@ -1263,10 +1354,14 @@ Along the way, you have learned to use these functions: | `node_is_min()` | flags the node(s) with the minimum score, for highlighting | | `node_in_structural()` | structurally equivalent classes (same tie partners; `cluster`, `distance`, `k`, `range` options) | | `node_in_regular()`, `node_in_automorphic()` | regularly and automorphically equivalent classes (same role; interchangeable) | -| `node_x_tie()`, `node_x_triad()`, `node_x_path()` | the tie, triad, and path censuses behind the three equivalences | +| `node_in_motif()` | classes of similar local embedding, from a triad or tetrad census | +| `node_x_tie()`, `node_x_triad()`, `node_x_path()` | the tie, triad, and path censuses behind the census-based equivalences | +| `regularity_rolesim()`, `regularity_rege()` | the recursive similarity matrices behind `node_in_regular()` | | `node_in_equivalence()` | generic equivalence classes from any `node_x_*()` census | | `plot()` on a membership vector | draws the dendrogram and its cut-point | | `plot(as_matrix(net), membership = )` | draws the blockmodel: the matrix sorted into blocks | +| `net_by_inconsistency()` | scores how far a partition's blocks are from ideal (0 is perfect); `blocks` sets which ideals | +| `node_in_block()` | searches directly for the partition that best fits an ideal block structure | | `summary(census, membership = )` | averages each class's census profile | | `to_blocks()` | contracts a network into a reduced graph of positions | | `graphr(..., node_color = , node_size = )` | maps memberships or measures onto the graph | From 691c67451f9592837d9e1f606db84639c7376e4b Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 16:01:33 +0200 Subject: [PATCH 15/68] Added net/node_by_decay() and net/node_by_integration() --- NAMESPACE | 4 + NEWS.md | 7 ++ R/measure_centrality_closeness.R | 138 +++++++++++++++++++++-- man/measure_centralisation_close.Rd | 41 ++++++- tests/testthat/test-measure_centrality.R | 59 ++++++++++ 5 files changed, 237 insertions(+), 12 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index d7e8a02..f0617ae 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -25,6 +25,7 @@ export(net_by_congruency) export(net_by_connectedness) export(net_by_core) export(net_by_cyclicality) +export(net_by_decay) export(net_by_degree) export(net_by_density) export(net_by_diameter) @@ -43,6 +44,7 @@ export(net_by_independence) export(net_by_infection_complete) export(net_by_infection_peak) export(net_by_infection_total) +export(net_by_integration) export(net_by_length) export(net_by_modularity) export(net_by_outdegree) @@ -85,6 +87,7 @@ export(node_by_brokering_exclusivity) export(node_by_closeness) export(node_by_constraint) export(node_by_coreness) +export(node_by_decay) export(node_by_deg) export(node_by_degree) export(node_by_distance) @@ -103,6 +106,7 @@ export(node_by_hub) export(node_by_indegree) export(node_by_induced) export(node_by_information) +export(node_by_integration) export(node_by_kcoreness) export(node_by_leverage) export(node_by_multidegree) diff --git a/NEWS.md b/NEWS.md index 74c07dd..f848b28 100644 --- a/NEWS.md +++ b/NEWS.md @@ -12,6 +12,13 @@ - Added `net_by_cyclicality()` for detecting generalised exchange - Added `net_by_compactness()` for the average closeness of all pairs of nodes +- Added `node_by_decay()` and `net_by_decay()` for decay centrality, which + weights alters by distance instead of truncating at a cutoff +- Added `node_by_integration()` and `net_by_integration()` for Valente and + Foreman's integration and radiality + - Note these two centralizations normalize over node scores rather + than reusing `net_by_reach()`'s denominator, which assumes scores bounded + by N-1 and would return negative values here - Added `net_by_inconsistency()`, which scores how far a partition's blocks depart from ideal types (`nul`, `com`, `reg`, `rdo`, `cdo`, `dnc`), generalising `net_by_factions()` beyond structural equivalence diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index 73ba6cf..98fae44 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -13,7 +13,11 @@ #' for disconnected networks. #' - `node_by_reach()` measures nodes' reach centrality, #' or how many nodes they can reach within _k_ steps. -#' - `node_by_information()` measures nodes' information centrality or +#' - `node_by_decay()` measures nodes' decay centrality, +#' a distance-weighted generalisation of reach centrality. +#' - `node_by_integration()` measures nodes' integration or radiality, +#' which weights alters by how close they are rather than counting them. +#' - `node_by_information()` measures nodes' information centrality or #' current-flow closeness centrality. #' - `node_by_eccentricity()` measures nodes' eccentricity or maximum distance #' from another node in the network. @@ -32,14 +36,14 @@ #' @template param_data #' @template param_norm #' @template param_dir +#' @template param_cutoff #' @family closeness #' @family centrality #' @template node_measure NULL #' @rdname measure_central_close -#' @param cutoff Maximum path length to use during calculations. -#' @section Closeness centrality: +#' @section Closeness centrality: #' Closeness centrality, status centrality, or barycenter centrality is #' defined as the reciprocal of the farness or distance, \eqn{d}, #' from a node to all other nodes in the network: @@ -148,8 +152,89 @@ node_by_reach <- function(.data, normalized = TRUE, cutoff = 2){ out } -#' @rdname measure_central_close -#' @section Information centrality: +#' @rdname measure_central_close +#' @param decay A proportion between 0 and 1 indicating how quickly +#' the contribution of more distant nodes decays. +#' By default 0.5, so that each additional step halves a node's contribution. +#' As `decay` approaches 0 this approaches degree centrality, +#' and as it approaches 1 this approaches the size of the node's component. +#' @section Decay centrality: +#' Where reach centrality counts how many others are within a fixed number of +#' steps, decay centrality weights every reachable other by how far away they +#' are, so that nearer nodes count for more: +#' \deqn{C_D(i) = \sum_{j, j \neq i} \delta^{d(i,j)-1}} +#' where \eqn{\delta} is the decay parameter and unreachable nodes contribute +#' nothing. This avoids having to choose a single cutoff, since the +#' contribution of distant nodes tapers off smoothly rather than being +#' truncated. Normalization is by \eqn{N-1}, the score achieved when a node +#' is adjacent to all others. +#' @references +#' ## On decay centrality +#' Jackson, Matthew O. 2008. +#' _Social and Economic Networks_. +#' Princeton: Princeton University Press. +#' @examples +#' node_by_decay(ison_adolescents) +#' @export +node_by_decay <- function(.data, normalized = TRUE, decay = 0.5, + direction = c("out", "in")){ + .data <- manynet::expect_nodes(.data) + if(decay < 0 | decay > 1) + manynet::snet_abort("`decay` must be a proportion between 0 and 1.") + # note that igraph's default mode ignores direction, which would treat a + # directed network as though every tie ran both ways + dists <- igraph::distances(manynet::as_igraph(.data), + mode = match.arg(direction)) + diag(dists) <- Inf # exclude self from own score + out <- rowSums(decay^(dists-1), na.rm = TRUE) # unreachable contribute 0 + if(normalized) out <- out/(manynet::net_nodes(.data)-1) + make_node_measure(out, .data) +} + +#' @rdname measure_central_close +#' @section Integration and radiality: +#' Integration centrality, also known as radiality, inverts the usual farness +#' logic: instead of summing distances, it sums how much _closer_ than the +#' network's diameter each other node is: +#' \deqn{C_I(i) = \sum_{j, j \neq i} (\Delta - d(i,j) + 1)} +#' where \eqn{\Delta} is the maximum finite distance in the network. +#' Nodes that are near to many others therefore score highly, +#' while unreachable pairs contribute nothing. +#' Normalization is by \eqn{(N-1)\Delta}. +#' +#' Valente and Foreman distinguish the two directions: +#' _integration_ is calculated on incoming ties, capturing how well a node is +#' reached by others, whereas _radiality_ is calculated on outgoing ties, +#' capturing how well a node reaches others. +#' Use `direction` to choose; in undirected networks they coincide. +#' @references +#' ## On integration and radiality +#' Valente, Thomas W., and Robert K. Foreman. 1998. +#' "Integration and radiality: Measuring the extent of an individual's +#' connectedness and reachability in a network". +#' _Social Networks_ 20(1): 89-105. +#' \doi{10.1016/S0378-8733(97)00007-5} +#' @examples +#' node_by_integration(ison_adolescents) +#' @export +node_by_integration <- function(.data, normalized = TRUE, + direction = c("in", "out")){ + .data <- manynet::expect_nodes(.data) + direction <- match.arg(direction) + dists <- igraph::distances(manynet::as_igraph(.data), + mode = ifelse(direction == "in", "in", "out")) + diag(dists) <- NA # exclude self from own score + maxd <- suppressWarnings(max(dists[is.finite(dists)])) + if(!is.finite(maxd)) maxd <- 0 # empty network + contrib <- maxd - dists + 1 + contrib[!is.finite(dists)] <- 0 # unreachable contribute nothing + out <- rowSums(contrib, na.rm = TRUE) + if(normalized && maxd > 0) out <- out/((manynet::net_nodes(.data)-1)*maxd) + make_node_measure(out, .data) +} + +#' @rdname measure_central_close +#' @section Information centrality: #' Information centrality, also known as current-flow centrality, #' is a hybrid measure relating to both path-length and walk-based measures. #' The information centrality of a node is the harmonic average of the @@ -382,7 +467,9 @@ tie_by_closeness <- function(.data, normalized = TRUE){ #' mode of a two-mode network, returning one score per mode #' (following Borgatti and Everett, 1997). #' - `net_by_reach()` measures a network's reach centralization. +#' - `net_by_decay()` measures a network's decay centralization. #' - `net_by_harmonic()` measures a network's harmonic centralization. +#' - `net_by_integration()` measures a network's integration centralization. #' #' All measures attempt to use as much information as they are offered, #' including whether the networks are directed, weighted, or multimodal. @@ -405,8 +492,7 @@ tie_by_closeness <- function(.data, normalized = TRUE){ #' "Network analysis of 2-mode data." #' _Social Networks_ 19(3): 243-269. #' \doi{10.1016/S0378-8733(96)00301-2} -#' @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. +#' @template param_cutoff #' @returns #' `net_by_*()` functions return a `network_measure` scalar; #' `mode_by_closeness()` returns a `mode_measure` numeric vector of length two, @@ -515,6 +601,44 @@ net_by_reach <- function(.data, normalized = TRUE, cutoff = 2){ make_network_measure(out, .data, call = deparse(sys.call())) } +#' @rdname measure_centralisation_close +#' @inheritParams measure_central_close +#' @section Decay and integration centralization: +#' Unlike reach centrality, decay and integration scores are not bounded above +#' by \eqn{N-1}: integration scores scale with the network's diameter. +#' Freeman's index therefore cannot use the same denominator as +#' `net_by_reach()`, which would return negative values. +#' Instead these apply the general centralization index over the _normalized_ +#' node scores, each of which lies in \eqn{[0,1]}, so the numerator's maximum +#' is \eqn{N-1} and the result is guaranteed to lie in \eqn{[0,1]}. +#' This is the same approach `net_by_closeness()` takes for two-mode networks. +#' @examples +#' net_by_decay(ison_adolescents) +#' @export +net_by_decay <- function(.data, normalized = TRUE, decay = 0.5, + direction = c("out", "in")){ + .data <- manynet::expect_nodes(.data) + decs <- node_by_decay(.data, normalized = normalized, decay = decay, + direction = match.arg(direction)) + out <- sum(max(decs) - decs) + if(normalized) out <- out / (length(decs) - 1) + make_network_measure(out, .data, call = deparse(sys.call())) +} + +#' @rdname measure_centralisation_close +#' @examples +#' net_by_integration(ison_adolescents) +#' @export +net_by_integration <- function(.data, normalized = TRUE, + direction = c("in", "out")){ + .data <- manynet::expect_nodes(.data) + ints <- node_by_integration(.data, normalized = normalized, + direction = match.arg(direction)) + out <- sum(max(ints) - ints) + if(normalized) out <- out / (length(ints) - 1) + make_network_measure(out, .data, call = deparse(sys.call())) +} + #' @rdname measure_centralisation_close #' @export net_by_harmonic <- function(.data, normalized = TRUE, cutoff = 2){ diff --git a/man/measure_centralisation_close.Rd b/man/measure_centralisation_close.Rd index 7efb66d..ecdc4f1 100644 --- a/man/measure_centralisation_close.Rd +++ b/man/measure_centralisation_close.Rd @@ -5,6 +5,8 @@ \alias{net_by_closeness} \alias{mode_by_closeness} \alias{net_by_reach} +\alias{net_by_decay} +\alias{net_by_integration} \alias{net_by_harmonic} \title{Measuring networks closeness-like centralisation} \usage{ @@ -14,12 +16,16 @@ mode_by_closeness(.data, normalized = TRUE, direction = c("all", "out", "in")) net_by_reach(.data, normalized = TRUE, cutoff = 2) +net_by_decay(.data, normalized = TRUE, decay = 0.5, direction = c("out", "in")) + +net_by_integration(.data, normalized = TRUE, direction = c("in", "out")) + net_by_harmonic(.data, normalized = TRUE, cutoff = 2) } \arguments{ -\item{.data}{A network object of class \code{mnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. -For more information on the standard coercion possible, -see \code{\link[manynet:as_tidygraph]{manynet::as_tidygraph()}}.} +\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{normalized}{Logical scalar, whether scores are normalized. Different denominators may be used depending on the measure, @@ -30,8 +36,17 @@ By default TRUE.} “in” on incoming ties, and "all" on either/the sum of the two. By default "all".} -\item{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.} +\item{cutoff}{Integer scalar, the maximum path length considered. +Paths longer than this are ignored, which restricts the measure to a +node's local neighbourhood. +Where a measure is defined over all paths by default, +a negative value or \code{NULL} imposes no limit.} + +\item{decay}{A proportion between 0 and 1 indicating how quickly +the contribution of more distant nodes decays. +By default 0.5, so that each additional step halves a node's contribution. +As \code{decay} approaches 0 this approaches degree centrality, +and as it approaches 1 this approaches the size of the node's component.} } \value{ \verb{net_by_*()} functions return a \code{network_measure} scalar; @@ -46,7 +61,9 @@ single score. mode of a two-mode network, returning one score per mode (following Borgatti and Everett, 1997). \item \code{net_by_reach()} measures a network's reach centralization. +\item \code{net_by_decay()} measures a network's decay centralization. \item \code{net_by_harmonic()} measures a network's harmonic centralization. +\item \code{net_by_integration()} measures a network's integration centralization. } All measures attempt to use as much information as they are offered, @@ -61,9 +78,23 @@ 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{Decay and integration centralization}{ + +Unlike reach centrality, decay and integration scores are not bounded above +by \eqn{N-1}: integration scores scale with the network's diameter. +Freeman's index therefore cannot use the same denominator as +\code{net_by_reach()}, which would return negative values. +Instead these apply the general centralization index over the \emph{normalized} +node scores, each of which lies in \eqn{[0,1]}, so the numerator's maximum +is \eqn{N-1} and the result is guaranteed to lie in \eqn{[0,1]}. +This is the same approach \code{net_by_closeness()} takes for two-mode networks. +} + \examples{ net_by_closeness(ison_southern_women, direction = "in") mode_by_closeness(ison_southern_women, direction = "in") +net_by_decay(ison_adolescents) +net_by_integration(ison_adolescents) } \references{ Borgatti, Stephen P., and Martin G. Everett. 1997. diff --git a/tests/testthat/test-measure_centrality.R b/tests/testthat/test-measure_centrality.R index 6629538..aba0a2b 100644 --- a/tests/testthat/test-measure_centrality.R +++ b/tests/testthat/test-measure_centrality.R @@ -142,3 +142,62 @@ test_that("tie_closeness works", { expect_equal(unname(tie_by_closeness(ison_adolescents)[1:3]), c(0.562,0.692,0.600), tolerance = 0.001) }) + +test_that("node decay centrality works", { + # as decay approaches 0, only immediate neighbours count, i.e. degree + expect_equal( + round(as.numeric(node_by_decay(ison_adolescents, decay = 1e-6, + normalized = FALSE))), + as.numeric(node_by_degree(ison_adolescents, normalized = FALSE))) + # as decay approaches 1, every reachable node counts equally, i.e. reach + expect_equal( + as.numeric(node_by_decay(ison_adolescents, decay = 1, normalized = FALSE)), + as.numeric(node_by_reach(ison_adolescents, cutoff = Inf, + normalized = FALSE))) + expect_equal(top3(node_by_decay(ison_adolescents)), c(0.4464, 0.75, 0.75)) + expect_error(node_by_decay(ison_adolescents, decay = 2)) + expect_length(node_by_decay(ison_southern_women), + manynet::net_nodes(ison_southern_women)) +}) + +test_that("node integration centrality works", { + expect_equal(top3(node_by_integration(ison_adolescents)), + c(0.6429, 0.8571, 0.8571)) + # in a complete network every node is maximally integrated + expect_true(all(node_by_integration(create_filled(6)) == 1)) + # direction matters in a directed network + expect_false(identical( + as.numeric(node_by_integration(ison_networkers, direction = "in")), + as.numeric(node_by_integration(ison_networkers, direction = "out")))) + expect_length(node_by_integration(ison_southern_women), + manynet::net_nodes(ison_southern_women)) +}) + +test_that("decay and integration centralization works", { + # both are bounded in [0,1], unlike a naive reach-style denominator + for (f in list(net_by_decay, net_by_integration)) { + for (net in list(ison_adolescents, ison_southern_women, + create_star(8), create_ring(8))) { + expect_gte(as.numeric(f(net)), 0) + expect_lte(as.numeric(f(net)), 1) + } + # a star is centralized, a ring is not + expect_equal(as.numeric(f(create_ring(8))), 0) + expect_gt(as.numeric(f(create_star(8))), 0) + } + expect_output(print(net_by_decay(ison_adolescents))) +}) + +test_that("node_by_decay respects tie direction", { + dir <- to_unweighted(ison_networkers) + # reaching out is not the same as being reached + expect_false(isTRUE(all.equal( + as.numeric(node_by_decay(dir, direction = "out")), + as.numeric(node_by_decay(dir, direction = "in"))))) + # in a one-way chain the first node reaches everyone and the last no one + chain <- matrix(0, 4, 4) + chain[cbind(1:3, 2:4)] <- 1 + out <- as.numeric(node_by_decay(chain, normalized = FALSE)) + expect_gt(out[1], out[4]) + expect_equal(out[4], 0) +}) From 94419e55686747b157c5a7af84f2ec39553c55c2 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 19:35:22 +0200 Subject: [PATCH 16/68] Improved measures to record what they computed, so results can be interpreted --- NEWS.md | 8 ++++++ R/class_metrics.R | 62 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/NEWS.md b/NEWS.md index f848b28..dadb5a1 100644 --- a/NEWS.md +++ b/NEWS.md @@ -10,6 +10,14 @@ ## Measures +- Improved measures to record what they computed, so results can be interpreted without + consulting the manual. `make_*_measure()` attaches three attributes: + - `measure`, name of measure actually calculated, + e.g. `node_by_degree()` reports "strength centrality" on a weighted network with `alpha = 1` + - `normalization`, one of `"normalized"`, `"scaled"`, `"proportion"`, or `"none"` + - `range`, theoretical range of the returned values + - These are additive: measures that do not set them behave exactly as before. + Surfacing them when printing is a companion change in `{manynet}`. - Added `net_by_cyclicality()` for detecting generalised exchange - Added `net_by_compactness()` for the average closeness of all pairs of nodes - Added `node_by_decay()` and `net_by_decay()` for decay centrality, which diff --git a/R/class_metrics.R b/R/class_metrics.R index 58622aa..957337d 100644 --- a/R/class_metrics.R +++ b/R/class_metrics.R @@ -22,14 +22,56 @@ make_tie_mark <- function(out, .data) { out } -make_node_measure <- function(out, .data) { +# Interpretive metadata #### + +# The vocabulary for how a measure's values have (or have not) been rescaled. +# The distinction matters for interpretation: +# "normalized" divided by a theoretical maximum, so values are comparable +# across different networks +# "scaled" divided by the observed maximum, so the top node is always +# exactly 1 and values rank nodes within one network only +# "proportion" shares of a fixed total, summing to 1 +# "none" raw values on the measure's own scale +NORMALIZATIONS <- c("normalized", "scaled", "proportion", "none") + +# Attaches the interpretive metadata shared by all measure classes. +# Each argument is optional; absent metadata is simply not set, so measures +# that do not (yet) declare it behave exactly as they did before. +set_measure_attributes <- function(out, measure = NULL, range = NULL, + normalization = NULL) { + if(!is.null(measure)) attr(out, "measure") <- measure + if(!is.null(range)) attr(out, "range") <- range + if(!is.null(normalization)) { + normalization <- match.arg(normalization, NORMALIZATIONS) + attr(out, "normalization") <- normalization + } + out +} + +# `scale` was the original (igraph-inherited) spelling of what is now `scaled`, +# named for symmetry with `normalized`. Accepts the old spelling and warns. +resolve_scaled <- function(scaled, scale = NULL) { + if(!is.null(scale)) { + # A real warning rather than `snet_warn()`, which is quiet by default: + # a renamed argument is something the user needs to act on. + warning("The `scale` argument has been renamed `scaled`, ", + "for symmetry with `normalized`. Please use `scaled` instead.", + call. = FALSE) + scaled <- scale + } + scaled +} + +make_node_measure <- function(out, .data, measure = NULL, range = NULL, + normalization = NULL) { if(manynet::is_labelled(.data)) names(out) <- manynet::node_names(.data) class(out) <- c("node_measure", class(out)) attr(out, "mode") <- manynet::node_is_mode(.data) - out + set_measure_attributes(out, measure, range, normalization) } -make_tie_measure <- function(out, .data) { +make_tie_measure <- function(out, .data, measure = NULL, range = NULL, + normalization = NULL) { class(out) <- c("tie_measure", class(out)) if(manynet::is_labelled(.data)){ tie_names <- attr(igraph::E(.data), "vnames") @@ -39,24 +81,26 @@ make_tie_measure <- function(out, .data) { } else { ties <- manynet::as_edgelist(.data)[,1:2] if(manynet::is_directed(.data)) - names(out) <- paste0(ties$from, "->", ties$to) else + names(out) <- paste0(ties$from, "->", ties$to) else names(out) <- paste0(ties$from, "-", ties$to) } - out + set_measure_attributes(out, measure, range, normalization) } -make_network_measure <- function(out, .data, call) { +make_network_measure <- function(out, .data, call, measure = NULL, + range = NULL, normalization = NULL) { class(out) <- c("network_measure", class(out)) attr(out, "mode") <- manynet::net_dims(.data) attr(out, "call") <- call - out + set_measure_attributes(out, measure, range, normalization) } -make_mode_measure <- function(out, .data, call) { +make_mode_measure <- function(out, .data, call, measure = NULL, + range = NULL, normalization = NULL) { class(out) <- c("mode_measure", "network_measure", class(out)) attr(out, "mode") <- manynet::net_dims(.data) attr(out, "call") <- call - out + set_measure_attributes(out, measure, range, normalization) } make_node_member <- function(out, .data) { From 96da6f146c86c51c00ee9848e3dc4edccfa2cf78 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 19:35:49 +0200 Subject: [PATCH 17/68] Added family-wide contract test sweeping every node-level centrality --- NEWS.md | 5 + .../test-measure_centrality_contract.R | 219 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 tests/testthat/test-measure_centrality_contract.R diff --git a/NEWS.md b/NEWS.md index dadb5a1..a64c775 100644 --- a/NEWS.md +++ b/NEWS.md @@ -18,6 +18,11 @@ - `range`, theoretical range of the returned values - These are additive: measures that do not set them behave exactly as before. Surfacing them when printing is a companion change in `{manynet}`. +- Added family-wide contract test sweeping every node-level centrality: + - that scores stay inside declared ranges + - that declared normalisations match values + - that arguments have an effect, + reporting any gaps as audit messages rather than failures - Added `net_by_cyclicality()` for detecting generalised exchange - Added `net_by_compactness()` for the average closeness of all pairs of nodes - Added `node_by_decay()` and `net_by_decay()` for decay centrality, which diff --git a/tests/testthat/test-measure_centrality_contract.R b/tests/testthat/test-measure_centrality_contract.R new file mode 100644 index 0000000..95c78cb --- /dev/null +++ b/tests/testthat/test-measure_centrality_contract.R @@ -0,0 +1,219 @@ +# Family-wide contract for the centrality measures. +# +# Rather than adding a test per function, this sweeps the whole roster and +# checks the promises the documentation makes: that a measure returns the +# right shape, that it stays inside the range it declares, that the +# normalisation it declares is the one it performed, and that its arguments +# actually do something. +# +# Where a function does not (yet) meet the contract, the sweep records an +# audit message rather than failing, so that the outstanding gaps are +# enumerated on every run instead of being either invisible or a red build. +# The list of audit messages is the remaining work; the aim is for it to +# shrink to empty. + +audit <- new.env(parent = emptyenv()) +audit$notes <- character() + +note_gap <- function(fn, gap) { + audit$notes <- c(audit$notes, paste0(fn, ": ", gap)) + invisible(NULL) +} + +# The roster of node-level centrality measures, with any arguments needed to +# make them applicable. Adding a measure here brings it under the contract. +node_centralities <- list( + node_by_degree = list(), + node_by_deg = list(), + node_by_indegree = list(), + node_by_outdegree = list(), + node_by_leverage = list(), + node_by_closeness = list(), + node_by_harmonic = list(), + node_by_reach = list(), + node_by_decay = list(), + node_by_integration = list(), + node_by_radiality = list(), + node_by_eccentricity = list(), + node_by_vitality = list(), + node_by_randomwalk = list(), + node_by_betweenness = list(), + node_by_induced = list(), + node_by_eigenvector = list(), + node_by_power = list(), + node_by_alpha = list(), + node_by_pagerank = list(), + node_by_hub = list(), + node_by_authority = list(), + node_by_subgraph = list() +) + +call_measure <- function(fn, args, .data) { + do.call(fn, c(list(.data), args)) +} + +# Documented exemptions from the "arguments are live" contract. These are +# deliberate declarations, not gaps: an eigenvector is defined only up to a +# scalar multiple, so its scores carry no absolute units to preserve and +# scaling is intrinsic rather than optional. +inert_arguments <- list( + node_by_eigenvector = c("normalized", "scaled") +) + +test_that("node centralities return a node_measure of the right length", { + g <- manynet::ison_adolescents + n <- manynet::net_nodes(g) + for (fn in names(node_centralities)) { + res <- call_measure(fn, node_centralities[[fn]], g) + expect_s3_class(res, "node_measure") + expect_length(as.numeric(res), n) + } +}) + +test_that("node centralities declare what they measured", { + g <- manynet::ison_adolescents + for (fn in names(node_centralities)) { + res <- call_measure(fn, node_centralities[[fn]], g) + if (is.null(attr(res, "measure"))) { + note_gap(fn, "declares no `measure` attribute") + next + } + expect_type(attr(res, "measure"), "character") + expect_true(attr(res, "normalization") %in% netrics:::NORMALIZATIONS) + } +}) + +test_that("node centralities stay inside the range they declare", { + g <- manynet::ison_adolescents + for (fn in names(node_centralities)) { + res <- call_measure(fn, node_centralities[[fn]], g) + rng <- attr(res, "range") + if (is.null(rng)) { + note_gap(fn, "declares no `range` attribute") + next + } + vals <- as.numeric(res) + vals <- vals[is.finite(vals)] + if (!length(vals)) next + if (min(vals) < rng[1] || max(vals) > rng[2]) + note_gap(fn, sprintf("returned [%.3f, %.3f], outside its declared [%s, %s]", + min(vals), max(vals), rng[1], rng[2])) + } + succeed() +}) + +test_that("declared normalisation matches what the values show", { + g <- manynet::ison_adolescents + for (fn in names(node_centralities)) { + res <- call_measure(fn, node_centralities[[fn]], g) + kind <- attr(res, "normalization") + if (is.null(kind)) next + vals <- as.numeric(res) + vals <- vals[is.finite(vals)] + if (!length(vals)) next + if (kind == "normalized" && (min(vals) < 0 || max(vals) > 1)) + note_gap(fn, "claims theoretical normalisation but leaves [0,1]") + # A scaled measure divides by the observed maximum, so exactly one node + # must sit at 1; a proportion sums to one across all nodes. + if (kind == "scaled" && !isTRUE(all.equal(max(vals), 1))) + note_gap(fn, sprintf("claims scaling but its maximum is %.4f, not 1", max(vals))) + if (kind == "proportion" && !isTRUE(all.equal(sum(vals), 1))) + note_gap(fn, sprintf("claims proportion but its values sum to %.4f, not 1", sum(vals))) + } + succeed() +}) + +test_that("arguments are live rather than decorative", { + g <- manynet::ison_adolescents + for (fn in names(node_centralities)) { + fargs <- formals(get(fn)) + base <- as.numeric(call_measure(fn, node_centralities[[fn]], g)) + for (flag in intersect(c("normalized", "scaled"), names(fargs))) { + if (flag %in% inert_arguments[[fn]]) next + # Toggle away from whatever the default is, rather than assuming it. + flipped <- !isTRUE(eval(fargs[[flag]])) + alt <- try(as.numeric(call_measure(fn, c(node_centralities[[fn]], + stats::setNames(list(flipped), flag)), g)), + silent = TRUE) + if (inherits(alt, "try-error")) { + note_gap(fn, sprintf("errors when `%s = %s`", flag, flipped)) + } else if (isTRUE(all.equal(base, alt))) { + note_gap(fn, sprintf("`%s` has no effect on the result", flag)) + } + } + } + succeed() +}) + +test_that("measures dispatch on the information they are given", { + g <- manynet::ison_adolescents + w <- manynet::mutate_ties(g, weight = c(1, 2, 3, 1, 5, 1, 2, 8, 1, 3)) + # Measures built only from the adjacency structure, which igraph provides + # no weighted form of, are exempt. + exempt <- c("node_by_power", "node_by_subgraph", "node_by_leverage", + "node_by_reach", "node_by_deg", "node_by_indegree", + "node_by_outdegree", "node_by_degree") + for (fn in setdiff(names(node_centralities), exempt)) { + unw <- as.numeric(call_measure(fn, node_centralities[[fn]], g)) + wtd <- try(as.numeric(call_measure(fn, node_centralities[[fn]], w)), + silent = TRUE) + if (inherits(wtd, "try-error")) { + note_gap(fn, "errors on a weighted network") + } else if (isTRUE(all.equal(unw, wtd))) { + note_gap(fn, "ignores tie weights") + } + } + succeed() +}) + +test_that("closeness-like variants relate as documented", { + g <- manynet::ison_adolescents + # Integration is an affine transformation of farness, so on a connected + # network it can never reorder nodes relative to closeness. + expect_equal(cor(as.numeric(node_by_integration(g)), + as.numeric(node_by_closeness(g)), + method = "spearman"), 1) + # Decay centrality is reached through harmonic centrality's `decay`. + expect_equal(as.numeric(node_by_decay(g, decay = 0.4)), + as.numeric(node_by_harmonic(g, decay = 0.4))) + # Radiality is integration read in the outgoing direction. + expect_equal(as.numeric(node_by_radiality(g)), + as.numeric(node_by_integration(g, direction = "out"))) +}) + +test_that("closeness vitality identifies cut nodes", { + g <- manynet::ison_adolescents + raw <- as.numeric(node_by_vitality(g, normalized = FALSE)) + # The Wiener index of a disconnected network is infinite, so removing a cut + # node gives negative infinity: a property of the definition, not a failure. + expect_true(any(!is.finite(raw))) + norm <- as.numeric(node_by_vitality(g)) + expect_true(all(is.finite(norm))) + expect_true(all(norm >= 0 & norm <= 1)) + # Cut nodes take the endpoint that negative infinity occupied. + expect_equal(norm[!is.finite(raw)], rep(0, sum(!is.finite(raw)))) +}) + +test_that("node_by_degree reports strength when weights are used", { + w <- manynet::mutate_ties(manynet::ison_adolescents, + weight = c(1, 2, 3, 1, 5, 1, 2, 8, 1, 3)) + expect_equal(attr(node_by_degree(w, alpha = 1), "measure"), + "strength centrality") + # Strength has no theoretical maximum, so this scales rather than normalises. + expect_equal(attr(node_by_degree(w, alpha = 1), "normalization"), "scaled") + expect_equal(attr(node_by_degree(w), "measure"), "degree centrality") +}) + +test_that("renamed `scale` argument still works, with a warning", { + g <- manynet::ison_adolescents + expect_warning(node_by_power(g, scale = TRUE), "renamed") +}) + +# Reported last so that the gaps appear together at the end of the run. +test_that("outstanding contract gaps are recorded", { + if (length(audit$notes)) { + message("Centrality contract gaps (", length(audit$notes), "):\n ", + paste(unique(audit$notes), collapse = "\n ")) + } + succeed() +}) From 6ce37039fedfb62174358ec70436cdc85e3089a4 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 19:38:14 +0200 Subject: [PATCH 18/68] Added `node_by_radiality()` as a shortcut for `node_by_integration(direction = "out")` --- NAMESPACE | 1 + NEWS.md | 4 +--- R/measure_centrality_closeness.R | 21 +++++++++++++++++++-- tests/testthat/test-motif_composition.R | 5 +++-- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index f0617ae..f1a98a3 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -115,6 +115,7 @@ export(node_by_outdegree) export(node_by_pagerank) export(node_by_posneg) export(node_by_power) +export(node_by_radiality) export(node_by_randomwalk) export(node_by_reach) export(node_by_reciprocity) diff --git a/NEWS.md b/NEWS.md index a64c775..1abed00 100644 --- a/NEWS.md +++ b/NEWS.md @@ -29,9 +29,7 @@ weights alters by distance instead of truncating at a cutoff - Added `node_by_integration()` and `net_by_integration()` for Valente and Foreman's integration and radiality - - Note these two centralizations normalize over node scores rather - than reusing `net_by_reach()`'s denominator, which assumes scores bounded - by N-1 and would return negative values here +- Added `node_by_radiality()` as a shortcut for `node_by_integration(direction = "out")` - Added `net_by_inconsistency()`, which scores how far a partition's blocks depart from ideal types (`nul`, `com`, `reg`, `rdo`, `cdo`, `dnc`), generalising `net_by_factions()` beyond structural equivalence diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index 98fae44..3a0cfd1 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -16,7 +16,11 @@ #' - `node_by_decay()` measures nodes' decay centrality, #' a distance-weighted generalisation of reach centrality. #' - `node_by_integration()` measures nodes' integration or radiality, -#' which weights alters by how close they are rather than counting them. +#' which weights alters by how close they are rather than counting them; +#' `node_by_radiality()` returns the `direction = 'out'` results. +#' Note that on a connected network integration ranks nodes identically to +#' closeness centrality, of which it is an affine transformation; +#' it differs only in how it treats unreachable nodes. #' - `node_by_information()` measures nodes' information centrality or #' current-flow closeness centrality. #' - `node_by_eccentricity()` measures nodes' eccentricity or maximum distance @@ -230,7 +234,20 @@ node_by_integration <- function(.data, normalized = TRUE, contrib[!is.finite(dists)] <- 0 # unreachable contribute nothing out <- rowSums(contrib, na.rm = TRUE) if(normalized && maxd > 0) out <- out/((manynet::net_nodes(.data)-1)*maxd) - make_node_measure(out, .data) + make_node_measure(out, .data, + measure = `if`(direction == "in", "integration centrality", + "radiality centrality"), + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) +} + +#' @rdname measure_central_close +#' @examples +#' node_by_radiality(ison_adolescents) +#' @export +node_by_radiality <- function(.data, normalized = TRUE){ + .data <- manynet::expect_nodes(.data) + node_by_integration(.data, normalized = normalized, direction = "out") } #' @rdname measure_central_close diff --git a/tests/testthat/test-motif_composition.R b/tests/testthat/test-motif_composition.R index 2b2350f..748a1b4 100644 --- a/tests/testthat/test-motif_composition.R +++ b/tests/testthat/test-motif_composition.R @@ -4,9 +4,10 @@ test_that("node_x_ties branches on network type", { expect_s3_class(ws, "node_motif") expect_equal(colnames(ws), c("Ties", "Sum", "Mean", "SD", "Min", "Median", "Max", "IQR")) - # the sum of a node's tie values is its weighted degree + # the sum of a node's tie values is its strength, i.e. `alpha = 1` expect_equal(unname(ws[, "Sum"]), - as.numeric(node_by_degree(ison_networkers, normalized = FALSE))) + as.numeric(node_by_degree(ison_networkers, normalized = FALSE, + alpha = 1))) expect_true(all(ws[, "Min"] <= ws[, "Max"], na.rm = TRUE)) # multiplex networks get one column per layer, plus diversity From 051f67f81c454b8c964bbf2b98595fd9f7a9e84b Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 20:55:19 +0200 Subject: [PATCH 19/68] Added `decay` argument to `node_by_harmonic()`, and added `node_by_decay()` as a shortcut for decay centrality --- NEWS.md | 3 +-- R/measure_centrality_closeness.R | 46 +++++++++++++++++++++----------- 2 files changed, 32 insertions(+), 17 deletions(-) diff --git a/NEWS.md b/NEWS.md index 1abed00..0eb7614 100644 --- a/NEWS.md +++ b/NEWS.md @@ -25,8 +25,7 @@ reporting any gaps as audit messages rather than failures - Added `net_by_cyclicality()` for detecting generalised exchange - Added `net_by_compactness()` for the average closeness of all pairs of nodes -- Added `node_by_decay()` and `net_by_decay()` for decay centrality, which - weights alters by distance instead of truncating at a cutoff +- Added `decay` argument to `node_by_harmonic()`, and added `node_by_decay()` as a shortcut for decay centrality - Added `node_by_integration()` and `net_by_integration()` for Valente and Foreman's integration and radiality - Added `node_by_radiality()` as a shortcut for `node_by_integration(direction = "out")` diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index 3a0cfd1..1e36bbf 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -101,6 +101,13 @@ node_by_closeness <- function(.data, normalized = TRUE, #' Since the harmonic mean performs better than the arithmetic mean on #' unconnected networks, i.e. networks with infinite distances, #' harmonic centrality is to be preferred in these cases. +#' +#' Harmonic centrality sums a decreasing function of each distance, +#' \eqn{\sum_j f(d(i,j))}, and setting `decay` simply swaps in a different +#' such function, \eqn{\delta^d}, giving decay centrality (see below). +#' Note that `node_by_closeness()` cannot be reached this way: it sums the +#' distances and inverts once, \eqn{1/\sum_j d(i,j)}, which is a different +#' order of aggregation that no choice of decay function reproduces. #' @references #' ## On harmonic centrality #' Marchiori, Massimo, and Vito Latora. 2000. @@ -112,12 +119,29 @@ node_by_closeness <- function(.data, normalized = TRUE, #' "Conceptual distance in social network analysis". #' _Journal of Social Structure_ 6(3). #' @export -node_by_harmonic <- function(.data, normalized = TRUE, cutoff = -1){ +node_by_harmonic <- function(.data, normalized = TRUE, cutoff = -1, + decay = NULL, direction = c("out", "in")){ .data <- manynet::expect_nodes(.data) - out <- igraph::harmonic_centrality(as_igraph(.data), # weighted if present - normalized = normalized, cutoff = cutoff) - out <- make_node_measure(out, .data) - out + direction <- match.arg(direction) + if(is.null(decay)){ + out <- igraph::harmonic_centrality(as_igraph(.data), # weighted if present + mode = direction, + normalized = normalized, cutoff = cutoff) + meas <- "harmonic centrality" + } else { + if(decay < 0 | decay > 1) + manynet::snet_abort("`decay` must be a proportion between 0 and 1.") + # note that igraph's default mode ignores direction, which would treat a + # directed network as though every tie ran both ways + dists <- igraph::distances(manynet::as_igraph(.data), mode = direction) + diag(dists) <- Inf # exclude self from own score + out <- rowSums(decay^(dists-1), na.rm = TRUE) # unreachable contribute 0 + if(normalized) out <- out/(manynet::net_nodes(.data)-1) + meas <- "decay centrality" + } + make_node_measure(out, .data, measure = meas, + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_central_close @@ -183,16 +207,8 @@ node_by_reach <- function(.data, normalized = TRUE, cutoff = 2){ node_by_decay <- function(.data, normalized = TRUE, decay = 0.5, direction = c("out", "in")){ .data <- manynet::expect_nodes(.data) - if(decay < 0 | decay > 1) - manynet::snet_abort("`decay` must be a proportion between 0 and 1.") - # note that igraph's default mode ignores direction, which would treat a - # directed network as though every tie ran both ways - dists <- igraph::distances(manynet::as_igraph(.data), - mode = match.arg(direction)) - diag(dists) <- Inf # exclude self from own score - out <- rowSums(decay^(dists-1), na.rm = TRUE) # unreachable contribute 0 - if(normalized) out <- out/(manynet::net_nodes(.data)-1) - make_node_measure(out, .data) + node_by_harmonic(.data, normalized = normalized, decay = decay, + direction = match.arg(direction)) } #' @rdname measure_central_close From 7c72b381c115df86fa92a5ad207f02429fd83c78 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 20:56:30 +0200 Subject: [PATCH 20/68] Improved `node_by_closeness()` to validate `direction` via `match.arg()` --- NEWS.md | 1 + R/measure_centrality_closeness.R | 26 ++++++++++++++------------ 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/NEWS.md b/NEWS.md index 0eb7614..2258c7c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -32,6 +32,7 @@ - Added `net_by_inconsistency()`, which scores how far a partition's blocks depart from ideal types (`nul`, `com`, `reg`, `rdo`, `cdo`, `dnc`), generalising `net_by_factions()` beyond structural equivalence +- Improved `node_by_closeness()` to validate `direction` via `match.arg()` ## Memberships diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index 1e36bbf..895998a 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -33,10 +33,10 @@ #' #' All measures attempt to use as much information as they are offered, #' including whether the networks are directed, weighted, or multimodal. -#' If this would produce unintended results, +#' If this would produce unintended results, #' first transform the salient properties using e.g. [to_undirected()] functions. -#' All centrality and centralization measures return normalized measures by default, -#' including for two-mode networks. +#' All centrality and centralization measures return normalised or scaled +#' measures where available, reported when the measure is printed. #' @template param_data #' @template param_norm #' @template param_dir @@ -67,14 +67,15 @@ NULL #' @examples #' node_by_closeness(ison_southern_women) #' @export -node_by_closeness <- function(.data, normalized = TRUE, - direction = "out", cutoff = NULL){ - +node_by_closeness <- function(.data, normalized = TRUE, + direction = c("out", "in", "all"), cutoff = NULL){ + .data <- manynet::expect_nodes(.data) - weights <- `if`(manynet::is_weighted(.data), + direction <- match.arg(direction) + weights <- `if`(manynet::is_weighted(.data), manynet::tie_weights(.data), NA) graph <- manynet::as_igraph(.data) - + # Do the calculations if (manynet::is_twomode(graph) & normalized){ # farness <- rowSums(igraph::distances(graph = graph)) @@ -84,12 +85,13 @@ node_by_closeness <- function(.data, normalized = TRUE, out <- closeness/(1/(other_set_size+2*set_size-2)) } else { cutoff <- if (is.null(cutoff)) -1 else cutoff - out <- igraph::closeness(graph = graph, vids = igraph::V(graph), mode = direction, + out <- igraph::closeness(graph = graph, vids = igraph::V(graph), mode = direction, cutoff = cutoff, weights = weights, normalized = normalized) } - out <- make_node_measure(out, .data) - out -} + make_node_measure(out, .data, measure = "closeness centrality", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) +} #' @rdname measure_central_close #' @section Harmonic centrality: From 067d1458cddbb8b2e37fbe17ebbcdd3e2320cf10 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 20:57:32 +0200 Subject: [PATCH 21/68] Fixed `node_by_reach()` counting the node itself so normalised scores could exceed 1 --- NEWS.md | 1 + R/measure_centrality_closeness.R | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/NEWS.md b/NEWS.md index 2258c7c..b866253 100644 --- a/NEWS.md +++ b/NEWS.md @@ -32,6 +32,7 @@ - Added `net_by_inconsistency()`, which scores how far a partition's blocks depart from ideal types (`nul`, `com`, `reg`, `rdo`, `cdo`, `dnc`), generalising `net_by_factions()` beyond structural equivalence +- Fixed `node_by_reach()` counting the node itself so normalised scores could exceed 1 - Improved `node_by_closeness()` to validate `direction` via `match.arg()` ## Memberships diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index 895998a..a1036f0 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -175,11 +175,12 @@ node_by_reach <- function(.data, normalized = TRUE, cutoff = 2){ tore <- manynet::as_matrix(.data)/mean(manynet::as_matrix(.data)) out <- 1/tore } else out <- igraph::distances(manynet::as_igraph(.data)) - diag(out) <- 0 + diag(out) <- Inf # exclude self from own score out <- rowSums(out <= cutoff) if(normalized) out <- out/(manynet::net_nodes(.data)-1) - out <- make_node_measure(out, .data) - out + make_node_measure(out, .data, measure = "reach centrality", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_central_close From 7c8fc7bb50b3bab66bbe0c73abaeb01811281deb Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 20:58:25 +0200 Subject: [PATCH 22/68] Fixed how `node_by_vitality()` treats cut nodes --- NEWS.md | 3 +++ R/measure_centrality_closeness.R | 31 ++++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index b866253..e95238b 100644 --- a/NEWS.md +++ b/NEWS.md @@ -33,6 +33,9 @@ from ideal types (`nul`, `com`, `reg`, `rdo`, `cdo`, `dnc`), generalising `net_by_factions()` beyond structural equivalence - Fixed `node_by_reach()` counting the node itself so normalised scores could exceed 1 +- Fixed how `node_by_vitality()` treats cut nodes + - Unnormalised returns `-Inf` for cut nodes as the Wiener index definition requires + - Normalised `node_by_vitality()` rescales finite scores onto `[0,1]` and places cut nodes at 0 - Improved `node_by_closeness()` to validate `direction` via `match.arg()` ## Memberships diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index a1036f0..2636baf 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -364,8 +364,14 @@ node_by_distance <- function(.data, from, to, normalized = TRUE){ #' @section Closeness vitality centrality: #' The closeness vitality of a node is the change in the sum of all distances #' in a network, also known as the Wiener Index, when that node is removed. -#' Note that the closeness vitality may be negative infinity if -#' removing that node would disconnect the network. +#' Since the Wiener Index of a disconnected network is infinite, +#' the unnormalised closeness vitality of a cut node — one whose removal +#' would disconnect the network — is negative infinity. +#' This is a property of the definition rather than a failure of it: +#' it picks out exactly the cut nodes. +#' Because that is awkward to work with, the normalised version rescales the +#' finite scores onto \eqn{[0,1]} and gives cut nodes a score of 0, +#' the endpoint that negative infinity occupies. #' Formally: #' \deqn{C_V(i) = \sum_{j,k} d(j,k) - \sum_{j,k} d(j,k,G\ i)} #' where \eqn{d(j,k,G\ i)} is the distance between nodes \eqn{j} and \eqn{k} @@ -383,11 +389,26 @@ node_by_vitality <- function(.data, normalized = TRUE){ .data <- manynet::expect_nodes(.data) .data <- manynet::as_igraph(.data) out <- vapply(manynet::snet_progress_nodes(.data), function(x){ - sum(igraph::distances(.data)) - + sum(igraph::distances(.data)) - sum(igraph::distances(manynet::delete_nodes(.data, x))) }, FUN.VALUE = numeric(1)) - if(normalized) out <- out/max(out) - make_node_measure(out, .data) + cuts <- !is.finite(out) + if(any(cuts)) + manynet::snet_info("Removing {sum(cuts)} node{?s} would disconnect this network, giving them infinite closeness vitality.") + if(normalized){ + # Dividing by the maximum would not bound these scores, since they can be + # negative; a min-max rescaling of the finite scores does, leaving the + # cut nodes at 0, the endpoint negative infinity occupies. + if(any(!cuts)){ + lims <- range(out[!cuts]) + out[!cuts] <- `if`(diff(lims) > 0, + (out[!cuts] - lims[1])/diff(lims), 1) + } + out[cuts] <- 0 + } + make_node_measure(out, .data, measure = "closeness vitality centrality", + range = `if`(normalized, c(0, 1), c(-Inf, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_central_close From d7dbc17d4b68956d02140fda3e34fade5a2cc1db Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 21:00:27 +0200 Subject: [PATCH 23/68] Moved `node_by_posneg()` (PN centrality) to eigenvector doc group --- NEWS.md | 2 ++ R/measure_centrality_degree.R | 32 ++++++---------------------- R/measure_centrality_eigen.R | 40 ++++++++++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 27 deletions(-) diff --git a/NEWS.md b/NEWS.md index e95238b..bf7a72a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -37,6 +37,8 @@ - Unnormalised returns `-Inf` for cut nodes as the Wiener index definition requires - Normalised `node_by_vitality()` rescales finite scores onto `[0,1]` and places cut nodes at 0 - Improved `node_by_closeness()` to validate `direction` via `match.arg()` +- Moved `node_by_posneg()` (PN centrality) to eigenvector doc group as a + matrix-inversion walk-based measure — Katz for signed networks ## Memberships diff --git a/R/measure_centrality_degree.R b/R/measure_centrality_degree.R index 69fc5b6..05c51b0 100644 --- a/R/measure_centrality_degree.R +++ b/R/measure_centrality_degree.R @@ -12,9 +12,8 @@ #' - `node_by_indegree()` returns the `direction = 'in'` results. #' - `node_by_outdegree()` returns the `direction = 'out'` results. #' - `node_by_multidegree()` measures the ratio between types of ties in a multiplex network. -#' - `node_by_posneg()` measures the PN (positive-negative) centrality of a signed network. #' - `node_by_leverage()` measures the leverage centrality of nodes in a network. -#' +#' #' All measures attempt to use as much information as they are offered, #' including whether the networks are directed, weighted, or multimodal. #' If this would produce unintended results, @@ -151,31 +150,12 @@ node_by_indegree <- function (.data, normalized = TRUE, alpha = 0){ node_by_multidegree <- function (.data, tie1, tie2){ .data <- manynet::expect_nodes(.data) stopifnot(manynet::is_multiplex(.data)) - out <- node_by_degree(manynet::to_uniplex(.data, tie1)) - + out <- node_by_degree(manynet::to_uniplex(.data, tie1)) - node_by_degree(manynet::to_uniplex(.data, tie2)) - make_node_measure(out, .data) -} - -#' @rdname measure_central_degree -#' @references -#' ## On signed centrality -#' Everett, Martin G., and Stephen P. Borgatti. 2014. -#' “Networks Containing Negative Ties.” -#' _Social Networks_ 38:111–20. -#' \doi{10.1016/j.socnet.2014.03.005} -#' @export -node_by_posneg <- function(.data){ - .data <- manynet::expect_nodes(.data) - stopifnot(manynet::is_signed(.data)) - pos <- manynet::as_matrix(manynet::to_unsigned(.data, keep = "positive")) - neg <- manynet::as_matrix(manynet::to_unsigned(.data, keep = "negative")) - nn <- manynet::net_nodes(.data) - pn <- pos-neg*2 - diag(pn) <- 0 - idmat <- diag(nn) - v1 <- matrix(1,nn,1) - out <- solve(idmat - ((pn%*%t(pn))/(4*(nn-1)^2))) %*% (idmat+( pn/(2*(nn-1)) )) %*% v1 - make_node_measure(out, .data) + # Bounded by construction rather than divided by a maximum: the difference + # of two normalised degrees. + make_node_measure(out, .data, measure = "multidegree centrality", + range = c(-1, 1), normalization = "none") } #' @rdname measure_central_degree diff --git a/R/measure_centrality_eigen.R b/R/measure_centrality_eigen.R index 4bc839e..aea6ce7 100644 --- a/R/measure_centrality_eigen.R +++ b/R/measure_centrality_eigen.R @@ -18,6 +18,9 @@ #' - `node_by_authority()` measures how well nodes in a network serve as #' authorities from many hubs. #' +#' - `node_by_posneg()` measures the PN (positive-negative) centrality of a +#' signed network. +#' #' All measures attempt to use as much information as they are offered, #' including whether the networks are directed, weighted, or multimodal. #' If this would produce unintended results, @@ -253,8 +256,43 @@ node_by_hub <- function(.data){ #' @export node_by_subgraph <- function(.data){ .data <- manynet::expect_nodes(.data) + # Subgraph centrality grows exponentially in the number of closed walks and + # has no theoretical maximum, so no normalisation is offered. make_node_measure(igraph::subgraph_centrality(manynet::as_igraph(.data)), - .data) + .data, measure = "subgraph centrality", + range = c(0, Inf), normalization = "none") +} + +#' @rdname measure_central_eigen +#' @section PN (positive-negative) centrality: +#' PN centrality extends walk-based centrality to signed networks. +#' Negative ties are weighted twice as heavily as positive ties, +#' \eqn{P - 2N}, and the measure is then obtained in closed form by matrix +#' inversion, so that — like alpha centrality, of which it is the signed +#' analogue — it counts walks of all lengths with a length discount rather +#' than counting only direct ties. +#' Scores centre on 1: nodes above 1 are advantaged by their pattern of +#' positive and negative ties, and those below 1 disadvantaged. +#' @references +#' ## On signed centrality +#' Everett, Martin G., and Stephen P. Borgatti. 2014. +#' “Networks Containing Negative Ties.” +#' _Social Networks_ 38:111–20. +#' \doi{10.1016/j.socnet.2014.03.005} +#' @export +node_by_posneg <- function(.data){ + .data <- manynet::expect_nodes(.data) + stopifnot(manynet::is_signed(.data)) + pos <- manynet::as_matrix(manynet::to_unsigned(.data, keep = "positive")) + neg <- manynet::as_matrix(manynet::to_unsigned(.data, keep = "negative")) + nn <- manynet::net_nodes(.data) + pn <- pos-neg*2 + diag(pn) <- 0 + idmat <- diag(nn) + v1 <- matrix(1,nn,1) + out <- solve(idmat - ((pn%*%t(pn))/(4*(nn-1)^2))) %*% (idmat+( pn/(2*(nn-1)) )) %*% v1 + make_node_measure(out, .data, measure = "PN centrality", + range = c(0, Inf), normalization = "none") } # Eigenvector-like centralities #### From c1de2ed6d5863e8388b44e68306e1c9be84bee6e Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 21:01:56 +0200 Subject: [PATCH 24/68] Fixed `node_by_degree()` to default to `alpha = 0` to match documentation --- NEWS.md | 1 + R/measure_centrality_degree.R | 41 +++++++++++++++--------- tests/testthat/test-measure_centrality.R | 8 ++++- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/NEWS.md b/NEWS.md index bf7a72a..473047e 100644 --- a/NEWS.md +++ b/NEWS.md @@ -32,6 +32,7 @@ - Added `net_by_inconsistency()`, which scores how far a partition's blocks depart from ideal types (`nul`, `com`, `reg`, `rdo`, `cdo`, `dnc`), generalising `net_by_factions()` beyond structural equivalence +- Fixed `node_by_degree()` to default to `alpha = 0` to match documentation - Fixed `node_by_reach()` counting the node itself so normalised scores could exceed 1 - Fixed how `node_by_vitality()` treats cut nodes - Unnormalised returns `-Inf` for cut nodes as the Wiener index definition requires diff --git a/R/measure_centrality_degree.R b/R/measure_centrality_degree.R index 05c51b0..859f493 100644 --- a/R/measure_centrality_degree.R +++ b/R/measure_centrality_degree.R @@ -81,45 +81,56 @@ NULL #' indegree (degree of incoming ties). #' @importFrom manynet as_igraph is_weighted tie_weights is_twomode is_complex #' @export -node_by_degree <- function (.data, normalized = TRUE, alpha = 1, +node_by_degree <- function (.data, normalized = TRUE, alpha = 0, direction = c("all","out","in")){ .data <- manynet::expect_nodes(.data) graph <- manynet::as_igraph(.data) - weights <- `if`(manynet::is_weighted(.data), + weights <- `if`(manynet::is_weighted(.data), manynet::tie_weights(.data), NA) direction <- match.arg(direction) - + # Do the calculations if (manynet::is_twomode(graph) & normalized){ - degrees <- igraph::degree(graph = graph, - v = igraph::V(graph), - mode = direction, + degrees <- igraph::degree(graph = graph, + v = igraph::V(graph), + mode = direction, loops = manynet::is_complex(.data)) - other_set_size <- ifelse(igraph::V(graph)$type, - sum(!igraph::V(graph)$type), + other_set_size <- ifelse(igraph::V(graph)$type, + sum(!igraph::V(graph)$type), sum(igraph::V(graph)$type)) out <- degrees/other_set_size + # Each mode's degree is divided by the size of the opposite mode, + # which is that mode's theoretical maximum. + meas <- "degree centrality"; rng <- c(0, 1); norm <- "normalized" } else { if (all(is.na(weights))) { - out <- igraph::degree(graph = graph, v = igraph::V(graph), - mode = direction, + out <- igraph::degree(graph = graph, v = igraph::V(graph), + mode = direction, loops = manynet::is_complex(.data), normalized = normalized) + meas <- "degree centrality" + rng <- `if`(normalized, c(0, 1), c(0, Inf)) + norm <- `if`(normalized, "normalized", "none") } else { - ki <- igraph::degree(graph = graph, v = igraph::V(graph), - mode = direction, + ki <- igraph::degree(graph = graph, v = igraph::V(graph), + mode = direction, loops = manynet::is_complex(.data)) - si <- igraph::strength(graph = graph, vids = igraph::V(graph), + si <- igraph::strength(graph = graph, vids = igraph::V(graph), mode = direction, loops = manynet::is_complex(.data), weights = weights) out <- ki * (si/ki)^alpha out[is.nan(out)] <- 0 + # Strength has no theoretical maximum, so `normalized` here divides by + # the observed maximum: the result scales rather than normalises. if(normalized) out <- out/max(out) + meas <- `if`(alpha == 0, "degree centrality", "strength centrality") + rng <- `if`(normalized, c(0, 1), c(0, Inf)) + norm <- `if`(normalized, "scaled", "none") } } - out <- make_node_measure(out, .data) - out + make_node_measure(out, .data, measure = meas, range = rng, + normalization = norm) } #' @rdname measure_central_degree diff --git a/tests/testthat/test-measure_centrality.R b/tests/testthat/test-measure_centrality.R index aba0a2b..2ed7888 100644 --- a/tests/testthat/test-measure_centrality.R +++ b/tests/testthat/test-measure_centrality.R @@ -134,8 +134,14 @@ test_that("net_measure class works", { # ####### Edge centrality test_that("tie_betweenness works", { - expect_equal(unname(tie_by_betweenness(ison_adolescents)[1:3]), + # The raw counts of shortest paths through each tie. + expect_equal(unname(tie_by_betweenness(ison_adolescents, + normalized = FALSE)[1:3]), c(7,3,5), tolerance = 0.001) + # `normalized = TRUE` now divides by the number of node pairs whose + # shortest paths could run through a tie, here choose(8, 2) = 28. + expect_equal(unname(tie_by_betweenness(ison_adolescents)[1:3]), + c(7,3,5)/28, tolerance = 0.001) }) test_that("tie_closeness works", { From 438855db815aabe3ee2b698adf4d5678f995559b Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 21:05:35 +0200 Subject: [PATCH 25/68] Fixed `node_by_eigenvector()` discarding tie weights it had computed, silently returning unweighted scores for weighted networks --- NEWS.md | 1 + R/measure_centrality_eigen.R | 39 ++++++++++++++++++++++-------------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/NEWS.md b/NEWS.md index 473047e..f1ceac9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -34,6 +34,7 @@ `net_by_factions()` beyond structural equivalence - Fixed `node_by_degree()` to default to `alpha = 0` to match documentation - Fixed `node_by_reach()` counting the node itself so normalised scores could exceed 1 +- Fixed `node_by_eigenvector()` discarding tie weights it had computed, silently returning unweighted scores for weighted networks - Fixed how `node_by_vitality()` treats cut nodes - Unnormalised returns `-Inf` for cut nodes as the Wiener index definition requires - Normalised `node_by_vitality()` rescales finite scores onto `[0,1]` and places cut nodes at 0 diff --git a/R/measure_centrality_eigen.R b/R/measure_centrality_eigen.R index aea6ce7..c68144a 100644 --- a/R/measure_centrality_eigen.R +++ b/R/measure_centrality_eigen.R @@ -59,37 +59,46 @@ NULL #' @examples #' node_by_eigenvector(ison_southern_women) #' @export -node_by_eigenvector <- function(.data, normalized = TRUE, scale = TRUE){ - +node_by_eigenvector <- function(.data, normalized = TRUE, scaled = TRUE, + scale = NULL){ + .data <- manynet::expect_nodes(.data) - weights <- `if`(manynet::is_weighted(.data), - manynet::tie_weights(.data), NA) + scaled <- resolve_scaled(scaled, scale) + weights <- `if`(manynet::is_weighted(.data), + manynet::tie_weights(.data), NULL) graph <- manynet::as_igraph(.data) - - if(!normalized) manynet::snet_info("This function always returns a normalized value now.") - if(!scale) manynet::snet_info("This function always returns a scaled value now.") - - if(!manynet::is_connected(.data)) + + # An eigenvector is only defined up to a scalar multiple, so its scores + # carry no absolute units: scaling to the observed maximum is intrinsic to + # the measure rather than an option. (igraph removed the choice in 2.1.1.) + # Neither is there a theoretical maximum to normalise against. + if(!normalized || !scaled) + manynet::snet_info("Eigenvector scores are defined only up to a scalar multiple, so they are always scaled to the observed maximum; `normalized` and `scaled` have no effect here.") + + 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)){ - out <- igraph::eigen_centrality(graph = graph, + out <- igraph::eigen_centrality(graph = graph, directed = manynet::is_directed(graph), + weights = weights, options = igraph::arpack_defaults())$vector } else { + # The projections carry their own (co-membership count) weights, + # which igraph picks up from the graph itself. eigen1 <- manynet::to_mode1(graph) - eigen1 <- igraph::eigen_centrality(graph = eigen1, + eigen1 <- igraph::eigen_centrality(graph = eigen1, directed = manynet::is_directed(eigen1), options = igraph::arpack_defaults())$vector eigen2 <- manynet::to_mode2(graph) - eigen2 <- igraph::eigen_centrality(graph = eigen2, + eigen2 <- igraph::eigen_centrality(graph = eigen2, directed = manynet::is_directed(eigen2), options = igraph::arpack_defaults())$vector out <- c(eigen1, eigen2) } - out <- make_node_measure(out, .data) - out + make_node_measure(out, .data, measure = "eigenvector centrality", + range = c(0, 1), normalization = "scaled") } #' @rdname measure_central_eigen From 7f6af70185d43fc035b5b6392ddb9810917959cc Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 21:08:03 +0200 Subject: [PATCH 26/68] Fixed betweenness centrality measures accepting normalisation and direction inaccurately --- NEWS.md | 3 ++ R/measure_centrality_between.R | 64 +++++++++++++++++++++----------- R/measure_centrality_closeness.R | 17 +++++++-- 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/NEWS.md b/NEWS.md index f1ceac9..f6b45ac 100644 --- a/NEWS.md +++ b/NEWS.md @@ -33,12 +33,15 @@ from ideal types (`nul`, `com`, `reg`, `rdo`, `cdo`, `dnc`), generalising `net_by_factions()` beyond structural equivalence - Fixed `node_by_degree()` to default to `alpha = 0` to match documentation +- Fixed `mode_by_betweenness()` to accepts only `"all"` and `"in"`, as implemented - Fixed `node_by_reach()` counting the node itself so normalised scores could exceed 1 - Fixed `node_by_eigenvector()` discarding tie weights it had computed, silently returning unweighted scores for weighted networks +- Fixed `tie_by_betweenness()`, `node_by_randomwalk()` and `node_by_betweenness()` (when given a `cutoff`) accepting `normalized` and then ignoring it - Fixed how `node_by_vitality()` treats cut nodes - Unnormalised returns `-Inf` for cut nodes as the Wiener index definition requires - Normalised `node_by_vitality()` rescales finite scores onto `[0,1]` and places cut nodes at 0 - Improved `node_by_closeness()` to validate `direction` via `match.arg()` +- Removed `direction` from `net_by_betweenness()` which never used it - Moved `node_by_posneg()` (PN centrality) to eigenvector doc group as a matrix-inversion walk-based measure — Katz for signed networks diff --git a/R/measure_centrality_between.R b/R/measure_centrality_between.R index 333951d..aba37b8 100644 --- a/R/measure_centrality_between.R +++ b/R/measure_centrality_between.R @@ -60,19 +60,17 @@ node_by_betweenness <- function(.data, normalized = TRUE, betw_scores/(2*(set_size-1)*(other_set_size-1)), betw_scores/(1/2*other_set_size*(other_set_size-1)+1/2*(set_size-1)*(set_size-2)+(set_size-1)*(other_set_size-1))) } else { - if (is.null(cutoff)) { - out <- igraph::betweenness(graph = graph, v = igraph::V(graph), - directed = manynet::is_directed(graph), weights = weights, - normalized = normalized) - } else { - out <- igraph::betweenness(graph = graph, v = igraph::V(graph), - directed = manynet::is_directed(graph), - cutoff = cutoff, - weights = weights) - } + # `igraph::betweenness()` accepts a cutoff and normalization together, + # so limiting path length does not preclude normalizing the result. + out <- igraph::betweenness(graph = graph, v = igraph::V(graph), + directed = manynet::is_directed(graph), + weights = weights, + cutoff = `if`(is.null(cutoff), -1, cutoff), + normalized = normalized) } - out <- make_node_measure(out, .data) - out + make_node_measure(out, .data, measure = "betweenness centrality", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_central_between @@ -187,8 +185,18 @@ tie_by_betweenness <- function(.data, normalized = TRUE){ eddies <- manynet::as_edgelist(.data) eddies <- paste(eddies[["from"]], eddies[["to"]], sep = "-") out <- igraph::edge_betweenness(.data) + # `igraph::edge_betweenness()` offers no normalization of its own, so we + # divide by the number of node pairs whose shortest paths could run through + # a tie, which is the theoretical maximum. + if(normalized){ + n <- manynet::net_nodes(.data) + pairs <- `if`(manynet::is_directed(.data), n*(n-1), n*(n-1)/2) + if(pairs > 0) out <- out/pairs + } names(out) <- eddies - make_tie_measure(out, .data) + make_tie_measure(out, .data, measure = "betweenness centrality", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } # Betweenness centralisation #### @@ -227,16 +235,24 @@ tie_by_betweenness <- function(.data, normalized = TRUE){ #' `net_by_betweenness()` returns a `network_measure` scalar; #' `mode_by_betweenness()` returns a `mode_measure` numeric vector of length two, #' giving one centralization score per mode. +#' @details +#' Betweenness centralisation has no directional variants: +#' `igraph::centr_betw()` derives directedness from the network itself, +#' so `net_by_betweenness()` takes no `direction` argument. +#' For the per-mode scores, `direction` chooses the comparison set rather +#' than a tie direction — `"all"` compares each mode's most central node +#' against every node in the network, whereas `"in"` compares it only +#' 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"`. NULL #' @rdname measure_centralisation_between #' @examples -#' net_by_betweenness(ison_southern_women, direction = "in") +#' net_by_betweenness(ison_southern_women) #' @export -net_by_betweenness <- function(.data, normalized = TRUE, - direction = c("all", "out", "in")) { +net_by_betweenness <- function(.data, normalized = TRUE) { .data <- manynet::expect_nodes(.data) - direction <- match.arg(direction) graph <- manynet::as_igraph(.data) if (manynet::is_twomode(.data)) { @@ -251,8 +267,10 @@ net_by_betweenness <- function(.data, normalized = TRUE, out <- igraph::centr_betw(graph = graph, normalized = normalized)$centralization } - out <- make_network_measure(out, .data, call = deparse(sys.call())) - out + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "betweenness centralisation", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_centralisation_between @@ -260,7 +278,7 @@ net_by_betweenness <- function(.data, normalized = TRUE, #' mode_by_betweenness(ison_southern_women, direction = "in") #' @export mode_by_betweenness <- function(.data, normalized = TRUE, - direction = c("all", "out", "in")) { + direction = c("all", "in")) { .data <- manynet::expect_nodes(.data) direction <- match.arg(direction) graph <- manynet::as_igraph(.data) @@ -308,7 +326,9 @@ mode_by_betweenness <- function(.data, normalized = TRUE, } out <- c("Mode 1" = out$nodes1, "Mode 2" = out$nodes2) } - out <- make_mode_measure(out, .data, call = deparse(sys.call())) - out + make_mode_measure(out, .data, call = deparse(sys.call()), + measure = "betweenness centralisation", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index 2636baf..df88aed 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -30,13 +30,19 @@ #' - `node_by_vitality()` measures a network's closeness vitality centrality, #' or the change in closeness centrality between networks with and without a #' given node. -#' +#' - `node_by_randomwalk()` measures nodes' random walk closeness centrality, +#' or the inverse of the average time a random walk takes to reach them. +#' #' All measures attempt to use as much information as they are offered, #' including whether the networks are directed, weighted, or multimodal. #' If this would produce unintended results, #' first transform the salient properties using e.g. [to_undirected()] functions. #' All centrality and centralization measures return normalised or scaled #' measures where available, reported when the measure is printed. +#' Most of these measures are _normalised_ against a theoretical maximum, +#' so that scores can be compared across networks; +#' `node_by_randomwalk()` and `node_by_distance()` have no such maximum and +#' are instead _scaled_ against the largest value observed in this network. #' @template param_data #' @template param_norm #' @template param_dir @@ -456,8 +462,13 @@ node_by_randomwalk <- function(.data, normalized = TRUE){ avg_ht <- mean(hitting_times[-i]) out[i] <- 1 / avg_ht } - - make_node_measure(out, .data) + + # Inverse mean hitting time has no theoretical maximum, so `normalized` + # divides by the observed maximum: the result scales rather than normalises. + if(normalized) out <- out/max(out) + make_node_measure(out, .data, measure = "random walk closeness centrality", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "scaled", "none")) } # This is a helper function to compute the Moore-Penrose generalized inverse From 7f74c2843da426bc8471472dfadd916117acf605 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 21:11:19 +0200 Subject: [PATCH 27/68] Renamed `scale` argument to `scaled`, the old spelling still works but warns --- NEWS.md | 3 + R/measure_centrality_between.R | 37 +++++++-- R/measure_centrality_closeness.R | 67 +++++++++++---- R/measure_centrality_degree.R | 39 ++++++--- R/measure_centrality_eigen.R | 112 +++++++++++++++++--------- man/measure_central_between.Rd | 18 ++++- man/measure_central_close.Rd | 62 ++++++++++++-- man/measure_central_degree.Rd | 26 +++--- man/measure_central_eigen.Rd | 61 ++++++++++++-- man/measure_centralisation_between.Rd | 21 +++-- 10 files changed, 338 insertions(+), 108 deletions(-) diff --git a/NEWS.md b/NEWS.md index f6b45ac..cbd0858 100644 --- a/NEWS.md +++ b/NEWS.md @@ -18,11 +18,14 @@ - `range`, theoretical range of the returned values - These are additive: measures that do not set them behave exactly as before. Surfacing them when printing is a companion change in `{manynet}`. +- Improved specificity of arguments, separating normalising from scaling + - Renamed `scale` argument to `scaled`, the old spelling still works but warns - Added family-wide contract test sweeping every node-level centrality: - that scores stay inside declared ranges - that declared normalisations match values - that arguments have an effect, reporting any gaps as audit messages rather than failures +- Corrected claim in documentation that all measures return normalized values by default - Added `net_by_cyclicality()` for detecting generalised exchange - Added `net_by_compactness()` for the average closeness of all pairs of nodes - Added `decay` argument to `node_by_harmonic()`, and added `node_by_decay()` as a shortcut for decay centrality diff --git a/R/measure_centrality_between.R b/R/measure_centrality_between.R index aba37b8..1d3d2cf 100644 --- a/R/measure_centrality_between.R +++ b/R/measure_centrality_between.R @@ -11,14 +11,26 @@ #' which uses an electrical current model for information spreading #' in contrast to the shortest paths model used by normal betweenness centrality. #' - `node_by_stress()` measures the stress centrality of nodes in a network. -#' - `tie_by_betweenness()` measures the number of shortest paths going through a tie. -#' +#' +#' These four differ in what they count: +#' `node_by_betweenness()` sums the _proportion_ of shortest paths between +#' each pair that run through a node, so every pair of nodes contributes at +#' most one unit however many shortest paths connect it; +#' `node_by_stress()` instead sums the raw _count_ of those paths, so pairs +#' joined by many equally short routes count for more; +#' `node_by_flow()` abandons shortest paths altogether for maximum flow, +#' crediting nodes that carry traffic along longer routes as well; +#' and `node_by_induced()` asks a different question again — not how much +#' passes through a node, but how much total betweenness the network would +#' lose if it were removed. +#' For ties rather than nodes, see [tie_by_betweenness()]. +#' #' All measures attempt to use as much information as they are offered, #' including whether the networks are directed, weighted, or multimodal. -#' If this would produce unintended results, +#' If this would produce unintended results, #' first transform the salient properties using e.g. [to_undirected()] functions. -#' All centrality and centralization measures return normalized measures by default, -#' including for two-mode networks. +#' All centrality and centralization measures return normalised or scaled +#' measures where available, reported when the measure is printed. #' @template param_data #' @template param_norm #' @family betweenness @@ -98,7 +110,8 @@ node_by_induced <- function(.data, normalized = TRUE, na.rm = TRUE), FUN.VALUE = numeric(1)) out <- endog - exog - make_node_measure(out, .data) + make_node_measure(out, .data, measure = "induced centrality", + range = c(-Inf, Inf), normalization = "none") } #' @rdname measure_central_between @@ -126,7 +139,11 @@ node_by_flow <- function(.data, normalized = TRUE){ gmode = ifelse(manynet::is_directed(.data), "digraph", "graph"), diag = manynet::is_complex(.data), cmode = ifelse(normalized, "normflow", "rawflow")) - make_node_measure(out, .data) + # `sna`'s "normflow" divides each node's mediated flow by the total flow, + # bounding the result by one. + make_node_measure(out, .data, measure = "flow betweenness centrality", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_central_between @@ -150,7 +167,11 @@ node_by_stress <- function(.data, normalized = TRUE){ gmode = ifelse(manynet::is_directed(.data), "digraph", "graph"), diag = manynet::is_complex(.data), rescale = normalized) - make_node_measure(out, .data) + # `sna::stresscent(rescale = TRUE)` divides by the sum of all scores, + # so the result is a set of shares rather than a [0,1] normalisation. + make_node_measure(out, .data, measure = "stress centrality", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "proportion", "none")) } # Tie betweenness centrality #### diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index df88aed..21772d3 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -314,7 +314,11 @@ node_by_information <- function(.data, normalized = TRUE){ gmode = ifelse(manynet::is_directed(.data), "digraph", "graph"), diag = manynet::is_complex(.data), rescale = normalized) - make_node_measure(out, .data) + # `sna::infocent(rescale = TRUE)` divides by the sum of all scores, + # so the result is a set of shares rather than a [0,1] normalisation. + make_node_measure(out, .data, measure = "information centrality", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "proportion", "none")) } #' @rdname measure_central_close @@ -337,8 +341,12 @@ node_by_eccentricity <- function(.data, normalized = TRUE){ manynet::snet_unavailable("Eccentricity centrality is only available for connected networks.") disties <- igraph::distances(as_igraph(.data)) out <- apply(disties, 1, max) + # Inverting the maximum distance bounds the result by 1, achieved by a node + # adjacent to every other. if(normalized) out <- 1/out - make_node_measure(out, .data) + make_node_measure(out, .data, measure = "eccentricity centrality", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } # - `node_eccentricity()` measures nodes' eccentricity or Koenig number, @@ -354,16 +362,27 @@ node_by_eccentricity <- function(.data, normalized = TRUE){ # make_node_measure(out, .data) # } -#' @rdname measure_central_close +#' @rdname measure_central_close #' @param from,to Index or name of a node to calculate distances from or to. +#' @section Geodesic distance: +#' Unlike the other functions documented here, `node_by_distance()` is not a +#' centrality index but a distance query: it reports each node's geodesic +#' distance from (or to) one named node, rather than summarising its position +#' with respect to the network as a whole. +#' It is grouped here because the closeness-like centralities are all built +#' from the same geodesic distances. #' @export node_by_distance <- function(.data, from, to, normalized = TRUE){ .data <- manynet::expect_nodes(.data) if(missing(from) && missing(to)) manynet::snet_abort("Either 'from' or 'to' must be specified.") - if(!missing(from)) out <- igraph::distances(manynet::as_igraph(.data), v = from) else + if(!missing(from)) out <- igraph::distances(manynet::as_igraph(.data), v = from) else if(!missing(to)) out <- igraph::distances(manynet::as_igraph(.data), to = to) - if(normalized) out <- out/max(out) - make_node_measure(out, .data) + # Distances have no theoretical maximum, so this divides by the largest + # distance observed from (or to) the named node. + if(normalized) out <- out/max(out) + make_node_measure(out, .data, measure = "geodesic distance", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "scaled", "none")) } #' @rdname measure_central_close @@ -521,7 +540,9 @@ tie_by_closeness <- function(.data, normalized = TRUE){ edge_adj <- manynet::to_ties(.data) out <- node_by_closeness(edge_adj, normalized = normalized) class(out) <- "numeric" - make_tie_measure(out, .data) + make_tie_measure(out, .data, measure = "closeness centrality", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } # Closeness centralisation #### @@ -591,8 +612,10 @@ net_by_closeness <- function(.data, normalized = TRUE, mode = direction, normalized = normalized)$centralization } - out <- make_network_measure(out, .data, call = deparse(sys.call())) - out + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "closeness centralisation", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_centralisation_close @@ -655,8 +678,10 @@ mode_by_closeness <- function(.data, normalized = TRUE, } out <- c("Mode 1" = out$nodes1, "Mode 2" = out$nodes2) } - out <- make_mode_measure(out, .data, call = deparse(sys.call())) - out + make_mode_measure(out, .data, call = deparse(sys.call()), + measure = "closeness centralisation", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_centralisation_close @@ -666,7 +691,10 @@ net_by_reach <- function(.data, normalized = TRUE, cutoff = 2){ reaches <- node_by_reach(.data, normalized = FALSE, cutoff = cutoff) out <- sum(max(reaches) - reaches) if(normalized) out <- out / sum(manynet::net_nodes(.data) - reaches) - make_network_measure(out, .data, call = deparse(sys.call())) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "reach centralisation", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_centralisation_close @@ -690,7 +718,10 @@ net_by_decay <- function(.data, normalized = TRUE, decay = 0.5, direction = match.arg(direction)) out <- sum(max(decs) - decs) if(normalized) out <- out / (length(decs) - 1) - make_network_measure(out, .data, call = deparse(sys.call())) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "decay centralisation", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_centralisation_close @@ -704,7 +735,10 @@ net_by_integration <- function(.data, normalized = TRUE, direction = match.arg(direction)) out <- sum(max(ints) - ints) if(normalized) out <- out / (length(ints) - 1) - make_network_measure(out, .data, call = deparse(sys.call())) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "integration centralisation", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_centralisation_close @@ -714,6 +748,9 @@ net_by_harmonic <- function(.data, normalized = TRUE, cutoff = 2){ harm <- node_by_harmonic(.data, normalized = FALSE, cutoff = cutoff) out <- sum(max(harm) - harm) if(normalized) out <- out / sum(manynet::net_nodes(.data) - harm) - make_network_measure(out, .data, call = deparse(sys.call())) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "harmonic centralisation", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } diff --git a/R/measure_centrality_degree.R b/R/measure_centrality_degree.R index 859f493..91a247f 100644 --- a/R/measure_centrality_degree.R +++ b/R/measure_centrality_degree.R @@ -16,10 +16,18 @@ #' #' All measures attempt to use as much information as they are offered, #' including whether the networks are directed, weighted, or multimodal. -#' If this would produce unintended results, +#' If this would produce unintended results, #' first transform the salient properties using e.g. [manynet::to_undirected()] functions. -#' All centrality and centralization measures return normalized measures by default, -#' including for two-mode networks. +#' All centrality and centralization measures return normalised or scaled +#' measures where available, reported when the measure is printed. +#' Note that a weighted network has no theoretical maximum degree, +#' so `node_by_degree()` there returns _scaled_ rather than normalised +#' scores, which rank nodes within this network but are not comparable +#' with those of another. +#' +#' `node_by_multidegree()` is the one measure here that is not reached by +#' dispatch: a multiplex network does not itself say _which_ two types of +#' tie to contrast, so `tie1` and `tie2` must be named. #' @template param_data #' @template param_norm #' @template param_dir @@ -185,7 +193,10 @@ node_by_leverage <- function(.data){ .data <- manynet::expect_nodes(.data) out <- (node_by_deg(.data) - node_by_neighbours_degree(.data))/ (node_by_deg(.data) + node_by_neighbours_degree(.data)) - make_node_measure(out, .data) + # Bounded by construction rather than divided by a maximum: a ratio of + # differences between a node's degree and its neighbours'. + make_node_measure(out, .data, measure = "leverage centrality", + range = c(-1, 1), normalization = "none") } # Degree-like centralities #### @@ -217,7 +228,9 @@ tie_by_degree <- function(.data, normalized = TRUE){ edge_adj <- manynet::to_ties(.data) out <- node_by_degree(edge_adj, normalized = normalized) class(out) <- "numeric" - make_tie_measure(out, .data) + make_tie_measure(out, .data, measure = "degree centrality", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } # Degree centralisation #### @@ -291,8 +304,10 @@ net_by_degree <- function(.data, normalized = TRUE, out <- igraph::centr_degree(graph = .data, mode = direction, normalized = normalized)$centralization } - out <- make_network_measure(out, .data, call = deparse(sys.call())) - out + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "degree centralisation", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_centralisation_degree @@ -323,12 +338,18 @@ mode_by_degree <- function(.data, normalized = TRUE, out$nodes2 <- sum(max(allcent[mode]) - allcent)/((ncol(mat) + nrow(mat) - 1) - (nrow(mat) - 1) / ncol(mat) - (nrow(mat) + ncol(mat) - 1)/ncol(mat)) } } else if (direction == "in" | direction == "out") { + # `direction` here selects the comparison set rather than a tie direction: + # each mode's most central node is compared only against the other nodes + # of its own mode. A two-mode incidence structure gives "in" and "out" no + # distinct meaning, so both take the same within-mode denominator. out$nodes1 <- sum(max(rowSums(mat)) - rowSums(mat))/((ncol(mat) - 1)*(nrow(mat) - 1)) out$nodes2 <- sum(max(colSums(mat)) - colSums(mat))/((ncol(mat) - 1)*(nrow(mat) - 1)) } out <- c("Mode 1" = out$nodes1, "Mode 2" = out$nodes2) - out <- make_mode_measure(out, .data, call = deparse(sys.call())) - out + make_mode_measure(out, .data, call = deparse(sys.call()), + measure = "degree centralisation", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_centralisation_degree diff --git a/R/measure_centrality_eigen.R b/R/measure_centrality_eigen.R index c68144a..6f49dd3 100644 --- a/R/measure_centrality_eigen.R +++ b/R/measure_centrality_eigen.R @@ -15,20 +15,32 @@ #' - `node_by_pagerank()` measures the pagerank centrality of nodes in a network. #' - `node_by_hub()` measures how well nodes in a network serve as hubs pointing #' to many authorities. -#' - `node_by_authority()` measures how well nodes in a network serve as +#' - `node_by_authority()` measures how well nodes in a network serve as #' authorities from many hubs. -#' +#' - `node_by_subgraph()` measures nodes' participation in all closed walks +#' in the network, weighting shorter walks more heavily. #' - `node_by_posneg()` measures the PN (positive-negative) centrality of a #' signed network. #' #' All measures attempt to use as much information as they are offered, #' including whether the networks are directed, weighted, or multimodal. -#' If this would produce unintended results, +#' If this would produce unintended results, #' first transform the salient properties using e.g. [to_undirected()] functions. -#' All centrality and centralization measures return normalized measures -#' by default, including for two-mode networks. +#' All centrality and centralization measures return normalised or scaled +#' measures where available, reported when the measure is printed. +#' +#' Walk-based measures are mostly unbounded, so few of them can be +#' _normalised_ against a theoretical maximum in the way that degree, +#' closeness and betweenness can. Most are instead _scaled_ against the +#' observed maximum, which ranks nodes within one network but does not +#' give scores that are comparable between networks. #' @template param_data #' @template param_norm +#' @param scaled Logical scalar, whether to divide the results by the maximum +#' observed in this network, so that the highest-scoring node takes the value +#' one. Note that, unlike normalisation against a theoretical maximum, scaled +#' scores are not comparable across different networks. +#' @param scale Deprecated; use `scaled` instead. #' @family eigenvector #' @family centrality #' @template node_measure @@ -46,7 +58,9 @@ NULL #' most routines solve the eigenvector equation \eqn{Ax = \lambda x}. #' Note that since `{igraph}` v2.1.1, #' the values will always be rescaled so that the maximum is 1. -#' @param scale Logical scalar, whether to rescale the vector so the maximum score is 1. +#' This is not a limitation so much as a property of the measure: +#' an eigenvector is defined only up to a scalar multiple, +#' so its scores carry no absolute units to preserve. #' @details #' We use `{igraph}` routines behind the scenes here for consistency and because they are often faster. #' For example, `igraph::eigencentrality()` is approximately 25% faster than `sna::evcent()`. @@ -127,13 +141,18 @@ node_by_eigenvector <- function(.data, normalized = TRUE, scaled = TRUE, #' @examples #' node_by_power(ison_southern_women, exponent = 0.5) #' @export -node_by_power <- function(.data, normalized = TRUE, scale = FALSE, exponent = 1){ - +node_by_power <- function(.data, normalized = TRUE, scaled = FALSE, + scale = NULL, exponent = 1){ + .data <- manynet::expect_nodes(.data) - weights <- `if`(manynet::is_weighted(.data), - manynet::tie_weights(.data), NA) + scaled <- resolve_scaled(scaled, scale) graph <- manynet::as_igraph(.data) - + + # `igraph::power_centrality()` operates on the unweighted adjacency matrix + # and offers no weights argument, so tie weights cannot be honoured here. + if(manynet::is_weighted(.data)) + manynet::snet_info("Power centrality ignores tie weights; consider {.fn node_by_alpha} for a weighted walk-based measure.") + if(var(node_by_deg(graph))==0){ manynet::snet_minor_info("All nodes have the same degree, so power centrality equals degree centrality.") exponent <- 0 @@ -141,24 +160,28 @@ node_by_power <- function(.data, normalized = TRUE, scale = FALSE, exponent = 1) # Do the calculations if (!manynet::is_twomode(graph)){ - out <- igraph::power_centrality(graph = graph, + out <- igraph::power_centrality(graph = graph, exponent = exponent, - rescale = scale) - if (normalized) out <- out / sqrt(1/2) + rescale = scaled) + if (normalized && !scaled) out <- out / sqrt(1/2) } else { eigen1 <- manynet::to_mode1(graph) - eigen1 <- igraph::power_centrality(graph = eigen1, + eigen1 <- igraph::power_centrality(graph = eigen1, exponent = exponent, - rescale = scale) + rescale = scaled) eigen2 <- manynet::to_mode2(graph) - eigen2 <- igraph::power_centrality(graph = eigen2, + eigen2 <- igraph::power_centrality(graph = eigen2, exponent = exponent, - rescale = scale) + rescale = scaled) out <- c(eigen1, eigen2) - if (normalized) out <- out / sqrt(1/2) + if (normalized && !scaled) out <- out / sqrt(1/2) } - out <- make_node_measure(out, .data) - out + # Power centrality is unbounded and may be negative (for a negative + # exponent), so `normalized` applies a constant factor rather than mapping + # onto [0,1]; `scaled = TRUE` instead returns shares summing to one. + make_node_measure(out, .data, measure = "power centrality", + range = `if`(scaled, c(0, 1), c(-Inf, Inf)), + normalization = `if`(scaled, "proportion", "none")) } #' @rdname measure_central_eigen @@ -198,9 +221,12 @@ node_by_power <- function(.data, normalized = TRUE, scale = FALSE, exponent = 1) #' @export node_by_alpha <- function(.data, alpha = 0.85){ .data <- manynet::expect_nodes(.data) - make_node_measure(igraph::alpha_centrality(manynet::as_igraph(.data), + # Alpha centrality is unbounded and can be negative, so there is no + # theoretical maximum to normalise against. + make_node_measure(igraph::alpha_centrality(manynet::as_igraph(.data), alpha = alpha), - .data) + .data, measure = "alpha centrality", + range = c(-Inf, Inf), normalization = "none") } #' @rdname measure_central_eigen @@ -212,8 +238,11 @@ node_by_alpha <- function(.data, alpha = 0.85){ #' @export node_by_pagerank <- function(.data){ .data <- manynet::expect_nodes(.data) + # PageRank is a stationary distribution over a random walk, so scores are + # already shares summing to one and no further rescaling applies. make_node_measure(igraph::page_rank(manynet::as_igraph(.data))$vector, - .data) + .data, measure = "pagerank centrality", + range = c(0, 1), normalization = "proportion") } #' @rdname measure_central_eigen @@ -224,18 +253,22 @@ node_by_pagerank <- function(.data){ #' _Journal of the ACM_ 46(5): 604–632. #' \doi{10.1145/324133.324140} #' @export -node_by_authority <- function(.data){ +node_by_authority <- function(.data, scaled = TRUE){ .data <- manynet::expect_nodes(.data) - make_node_measure(igraph::hits_scores(manynet::as_igraph(.data))$authority, - .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)), + normalization = `if`(scaled, "scaled", "none")) } -#' @rdname measure_central_eigen -#' @export -node_by_hub <- function(.data){ +#' @rdname measure_central_eigen +#' @export +node_by_hub <- function(.data, scaled = TRUE){ .data <- manynet::expect_nodes(.data) - make_node_measure(igraph::hits_scores(manynet::as_igraph(.data))$hub, - .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)), + normalization = `if`(scaled, "scaled", "none")) } #' @rdname measure_central_eigen @@ -334,7 +367,8 @@ tie_by_eigenvector <- function(.data, normalized = TRUE){ edge_adj <- manynet::to_ties(.data) out <- node_by_eigenvector(edge_adj, normalized = normalized) class(out) <- "numeric" - make_tie_measure(out, .data) + make_tie_measure(out, .data, measure = "eigenvector centrality", + range = c(0, 1), normalization = "scaled") } # Eigenvector centralisation #### @@ -392,8 +426,10 @@ net_by_eigenvector <- function(.data, normalized = TRUE){ out <- igraph::centr_eigen(manynet::as_igraph(.data), normalized = normalized)$centralization } - out <- make_network_measure(out, .data, call = deparse(sys.call())) - out + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "eigenvector centralisation", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_centralisation_eigen @@ -408,8 +444,10 @@ mode_by_eigenvector <- function(.data, normalized = TRUE){ normalized = normalized)$centralization, "Mode 2" = igraph::centr_eigen(manynet::as_igraph(manynet::to_mode2(.data)), normalized = normalized)$centralization) - out <- make_mode_measure(out, .data, call = deparse(sys.call())) - out + make_mode_measure(out, .data, call = deparse(sys.call()), + measure = "eigenvector centralisation", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } diff --git a/man/measure_central_between.Rd b/man/measure_central_between.Rd index 017ed9e..b55176e 100644 --- a/man/measure_central_between.Rd +++ b/man/measure_central_between.Rd @@ -44,15 +44,27 @@ These functions calculate common betweenness-related centrality measures for one which uses an electrical current model for information spreading in contrast to the shortest paths model used by normal betweenness centrality. \item \code{node_by_stress()} measures the stress centrality of nodes in a network. -\item \code{tie_by_betweenness()} measures the number of shortest paths going through a tie. } +These four differ in what they count: +\code{node_by_betweenness()} sums the \emph{proportion} of shortest paths between +each pair that run through a node, so every pair of nodes contributes at +most one unit however many shortest paths connect it; +\code{node_by_stress()} instead sums the raw \emph{count} of those paths, so pairs +joined by many equally short routes count for more; +\code{node_by_flow()} abandons shortest paths altogether for maximum flow, +crediting nodes that carry traffic along longer routes as well; +and \code{node_by_induced()} asks a different question again — not how much +passes through a node, but how much total betweenness the network would +lose if it were removed. +For ties rather than nodes, see \code{\link[=tie_by_betweenness]{tie_by_betweenness()}}. + All measures attempt to use as much information as they are offered, including whether the networks are directed, weighted, or multimodal. If this would produce unintended results, first transform the salient properties using e.g. \code{\link[manynet:to_undirected]{to_undirected()}} functions. -All centrality and centralization measures return normalized measures by default, -including for two-mode networks. +All centrality and centralization measures return normalised or scaled +measures where available, reported when the measure is printed. } \section{Betweenness centrality}{ diff --git a/man/measure_central_close.Rd b/man/measure_central_close.Rd index 7cbd254..978d443 100644 --- a/man/measure_central_close.Rd +++ b/man/measure_central_close.Rd @@ -7,6 +7,7 @@ \alias{node_by_reach} \alias{node_by_decay} \alias{node_by_integration} +\alias{node_by_radiality} \alias{node_by_information} \alias{node_by_eccentricity} \alias{node_by_distance} @@ -14,9 +15,20 @@ \alias{node_by_randomwalk} \title{Measuring nodes closeness-like centrality} \usage{ -node_by_closeness(.data, normalized = TRUE, direction = "out", cutoff = NULL) +node_by_closeness( + .data, + normalized = TRUE, + direction = c("out", "in", "all"), + cutoff = NULL +) -node_by_harmonic(.data, normalized = TRUE, cutoff = -1) +node_by_harmonic( + .data, + normalized = TRUE, + cutoff = -1, + decay = NULL, + direction = c("out", "in") +) node_by_reach(.data, normalized = TRUE, cutoff = 2) @@ -29,6 +41,8 @@ node_by_decay( node_by_integration(.data, normalized = TRUE, direction = c("in", "out")) +node_by_radiality(.data, normalized = TRUE) + node_by_information(.data, normalized = TRUE) node_by_eccentricity(.data, normalized = TRUE) @@ -87,7 +101,11 @@ or how many nodes they can reach within \emph{k} steps. \item \code{node_by_decay()} measures nodes' decay centrality, a distance-weighted generalisation of reach centrality. \item \code{node_by_integration()} measures nodes' integration or radiality, -which weights alters by how close they are rather than counting them. +which weights alters by how close they are rather than counting them; +\code{node_by_radiality()} returns the \code{direction = 'out'} results. +Note that on a connected network integration ranks nodes identically to +closeness centrality, of which it is an affine transformation; +it differs only in how it treats unreachable nodes. \item \code{node_by_information()} measures nodes' information centrality or current-flow closeness centrality. \item \code{node_by_eccentricity()} measures nodes' eccentricity or maximum distance @@ -97,14 +115,20 @@ given node. \item \code{node_by_vitality()} measures a network's closeness vitality centrality, or the change in closeness centrality between networks with and without a given node. +\item \code{node_by_randomwalk()} measures nodes' random walk closeness centrality, +or the inverse of the average time a random walk takes to reach them. } All measures attempt to use as much information as they are offered, including whether the networks are directed, weighted, or multimodal. If this would produce unintended results, first transform the salient properties using e.g. \code{\link[manynet:to_undirected]{to_undirected()}} functions. -All centrality and centralization measures return normalized measures by default, -including for two-mode networks. +All centrality and centralization measures return normalised or scaled +measures where available, reported when the measure is printed. +Most of these measures are \emph{normalised} against a theoretical maximum, +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{Closeness centrality}{ @@ -125,6 +149,13 @@ where \eqn{\frac{1}{d(i,j)} = 0} where there is no path between \eqn{i} and Since the harmonic mean performs better than the arithmetic mean on unconnected networks, i.e. networks with infinite distances, harmonic centrality is to be preferred in these cases. + +Harmonic centrality sums a decreasing function of each distance, +\eqn{\sum_j f(d(i,j))}, and setting \code{decay} simply swaps in a different +such function, \eqn{\delta^d}, giving decay centrality (see below). +Note that \code{node_by_closeness()} cannot be reached this way: it sums the +distances and inverts once, \eqn{1/\sum_j d(i,j)}, which is a different +order of aggregation that no choice of decay function reproduces. } \section{Reach centrality}{ @@ -206,12 +237,28 @@ where the distance from \eqn{i} to \eqn{j} is \eqn{\infty} if unconnected. As such it is only well defined for connected networks. } +\section{Geodesic distance}{ + +Unlike the other functions documented here, \code{node_by_distance()} is not a +centrality index but a distance query: it reports each node's geodesic +distance from (or to) one named node, rather than summarising its position +with respect to the network as a whole. +It is grouped here because the closeness-like centralities are all built +from the same geodesic distances. +} + \section{Closeness vitality centrality}{ The closeness vitality of a node is the change in the sum of all distances in a network, also known as the Wiener Index, when that node is removed. -Note that the closeness vitality may be negative infinity if -removing that node would disconnect the network. +Since the Wiener Index of a disconnected network is infinite, +the unnormalised closeness vitality of a cut node — one whose removal +would disconnect the network — is negative infinity. +This is a property of the definition rather than a failure of it: +it picks out exactly the cut nodes. +Because that is awkward to work with, the normalised version rescales the +finite scores onto \eqn{[0,1]} and gives cut nodes a score of 0, +the endpoint that negative infinity occupies. Formally: \deqn{C_V(i) = \sum_{j,k} d(j,k) - \sum_{j,k} d(j,k,G\ i)} where \eqn{d(j,k,G\ i)} is the distance between nodes \eqn{j} and \eqn{k} @@ -235,6 +282,7 @@ node_by_closeness(ison_southern_women) node_by_reach(ison_adolescents) node_by_decay(ison_adolescents) node_by_integration(ison_adolescents) +node_by_radiality(ison_adolescents) } \references{ \subsection{On closeness centrality}{ diff --git a/man/measure_central_degree.Rd b/man/measure_central_degree.Rd index d679978..e122f5d 100644 --- a/man/measure_central_degree.Rd +++ b/man/measure_central_degree.Rd @@ -7,14 +7,13 @@ \alias{node_by_outdegree} \alias{node_by_indegree} \alias{node_by_multidegree} -\alias{node_by_posneg} \alias{node_by_leverage} \title{Measuring nodes degree-like centrality} \usage{ node_by_degree( .data, normalized = TRUE, - alpha = 1, + alpha = 0, direction = c("all", "out", "in") ) @@ -26,8 +25,6 @@ node_by_indegree(.data, normalized = TRUE, alpha = 0) node_by_multidegree(.data, tie1, tie2) -node_by_posneg(.data) - node_by_leverage(.data) } \arguments{ @@ -79,7 +76,6 @@ there are several related shortcut functions: \item \code{node_by_outdegree()} returns the \code{direction = 'out'} results. } \item \code{node_by_multidegree()} measures the ratio between types of ties in a multiplex network. -\item \code{node_by_posneg()} measures the PN (positive-negative) centrality of a signed network. \item \code{node_by_leverage()} measures the leverage centrality of nodes in a network. } @@ -87,8 +83,16 @@ All measures attempt to use as much information as they are offered, including whether the networks are directed, weighted, or multimodal. If this would produce unintended results, first transform the salient properties using e.g. \code{\link[manynet:to_undirected]{manynet::to_undirected()}} functions. -All centrality and centralization measures return normalized measures by default, -including for two-mode networks. +All centrality and centralization measures return normalised or scaled +measures where available, reported when the measure is printed. +Note that a weighted network has no theoretical maximum degree, +so \code{node_by_degree()} there returns \emph{scaled} rather than normalised +scores, which rank nodes within this network but are not comparable +with those of another. + +\code{node_by_multidegree()} is the one measure here that is not reached by +dispatch: a multiplex network does not itself say \emph{which} two types of +tie to contrast, so \code{tie1} and \code{tie2} must be named. } \section{Degree centrality}{ @@ -143,14 +147,6 @@ Opsahl, Tore, Filip Agneessens, and John Skvoretz. 2010. \doi{10.1016/j.socnet.2010.03.006} } -\subsection{On signed centrality}{ - -Everett, Martin G., and Stephen P. Borgatti. 2014. -“Networks Containing Negative Ties.” -\emph{Social Networks} 38:111–20. -\doi{10.1016/j.socnet.2014.03.005} -} - \subsection{On leverage centrality}{ Joyce, Karen E., Paul J. Laurienti, Jonathan H. Burdette, and Satoru Hayasaka. 2010. diff --git a/man/measure_central_eigen.Rd b/man/measure_central_eigen.Rd index b8a353c..2566714 100644 --- a/man/measure_central_eigen.Rd +++ b/man/measure_central_eigen.Rd @@ -9,21 +9,30 @@ \alias{node_by_authority} \alias{node_by_hub} \alias{node_by_subgraph} +\alias{node_by_posneg} \title{Measuring nodes eigenvector-like centrality} \usage{ -node_by_eigenvector(.data, normalized = TRUE, scale = TRUE) +node_by_eigenvector(.data, normalized = TRUE, scaled = TRUE, scale = NULL) -node_by_power(.data, normalized = TRUE, scale = FALSE, exponent = 1) +node_by_power( + .data, + normalized = TRUE, + scaled = FALSE, + scale = NULL, + exponent = 1 +) node_by_alpha(.data, alpha = 0.85) node_by_pagerank(.data) -node_by_authority(.data) +node_by_authority(.data, scaled = TRUE) -node_by_hub(.data) +node_by_hub(.data, scaled = TRUE) node_by_subgraph(.data) + +node_by_posneg(.data) } \arguments{ \item{.data}{A network object of class \code{stocnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. @@ -35,7 +44,12 @@ Different denominators may be used depending on the measure, whether the object is one-mode or two-mode, and other arguments. By default TRUE.} -\item{scale}{Logical scalar, whether to rescale the vector so the maximum score is 1.} +\item{scaled}{Logical scalar, whether to divide the results by the maximum +observed in this network, so that the highest-scoring node takes the value +one. Note that, unlike normalisation against a theoretical maximum, scaled +scores are not comparable across different networks.} + +\item{scale}{Deprecated; use \code{scaled} instead.} \item{exponent}{Decay rate or attentuation factor for the Bonacich power centrality score. @@ -67,14 +81,24 @@ network. to many authorities. \item \code{node_by_authority()} measures how well nodes in a network serve as authorities from many hubs. +\item \code{node_by_subgraph()} measures nodes' participation in all closed walks +in the network, weighting shorter walks more heavily. +\item \code{node_by_posneg()} measures the PN (positive-negative) centrality of a +signed network. } All measures attempt to use as much information as they are offered, including whether the networks are directed, weighted, or multimodal. If this would produce unintended results, first transform the salient properties using e.g. \code{\link[manynet:to_undirected]{to_undirected()}} functions. -All centrality and centralization measures return normalized measures -by default, including for two-mode networks. +All centrality and centralization measures return normalised or scaled +measures where available, reported when the measure is printed. + +Walk-based measures are mostly unbounded, so few of them can be +\emph{normalised} against a theoretical maximum in the way that degree, +closeness and betweenness can. Most are instead \emph{scaled} against the +observed maximum, which ranks nodes within one network but does not +give scores that are comparable between networks. } \details{ We use \code{{igraph}} routines behind the scenes here for consistency and because they are often faster. @@ -92,6 +116,9 @@ Rather than performing this iteration, most routines solve the eigenvector equation \eqn{Ax = \lambda x}. Note that since \code{{igraph}} v2.1.1, the values will always be rescaled so that the maximum is 1. +This is not a limitation so much as a property of the measure: +an eigenvector is defined only up to a scalar multiple, +so its scores carry no absolute units to preserve. } \section{Power or beta (or Bonacich) centrality}{ @@ -151,6 +178,18 @@ Note though that because of the way spectral decomposition is used to calculate this measure, this is not a good measure for very large graphs. } +\section{PN (positive-negative) centrality}{ + +PN centrality extends walk-based centrality to signed networks. +Negative ties are weighted twice as heavily as positive ties, +\eqn{P - 2N}, and the measure is then obtained in closed form by matrix +inversion, so that — like alpha centrality, of which it is the signed +analogue — it counts walks of all lengths with a length discount rather +than counting only direct ties. +Scores centre on 1: nodes above 1 are advantaged by their pattern of +positive and negative ties, and those below 1 disadvantaged. +} + \examples{ node_by_eigenvector(ison_southern_women) node_by_power(ison_southern_women, exponent = 0.5) @@ -205,6 +244,14 @@ Estrada, Ernesto and Rodríguez-Velázquez, Juan A. 2005. \emph{Physical Review E} 71(5): 056103. \doi{10.1103/PhysRevE.71.056103} } + +\subsection{On signed centrality}{ + +Everett, Martin G., and Stephen P. Borgatti. 2014. +“Networks Containing Negative Ties.” +\emph{Social Networks} 38:111–20. +\doi{10.1016/j.socnet.2014.03.005} +} } \seealso{ Other eigenvector: diff --git a/man/measure_centralisation_between.Rd b/man/measure_centralisation_between.Rd index 50f6494..80f1ba2 100644 --- a/man/measure_centralisation_between.Rd +++ b/man/measure_centralisation_between.Rd @@ -6,13 +6,9 @@ \alias{mode_by_betweenness} \title{Measuring networks betweenness-like centralisation} \usage{ -net_by_betweenness(.data, normalized = TRUE, direction = c("all", "out", "in")) +net_by_betweenness(.data, normalized = TRUE) -mode_by_betweenness( - .data, - normalized = TRUE, - direction = c("all", "out", "in") -) +mode_by_betweenness(.data, normalized = TRUE, direction = c("all", "in")) } \arguments{ \item{.data}{A network object of class \code{stocnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. @@ -54,8 +50,19 @@ For two-mode networks the two modes have different theoretical maxima, so Freeman's general centralization index over the normalized node betweenness scores, whereas \code{mode_by_betweenness()} reports the per-mode scores directly. } +\details{ +Betweenness centralisation has no directional variants: +\code{igraph::centr_betw()} derives directedness from the network itself, +so \code{net_by_betweenness()} takes no \code{direction} argument. +For the per-mode scores, \code{direction} chooses the comparison set rather +than a tie direction — \code{"all"} compares each mode's most central node +against every node in the network, whereas \code{"in"} compares it only +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"}. +} \examples{ -net_by_betweenness(ison_southern_women, direction = "in") +net_by_betweenness(ison_southern_women) mode_by_betweenness(ison_southern_women, direction = "in") } \references{ From 220dcc76bfbc85a519a7c8b6b778c08ffe28a954 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 22:05:55 +0200 Subject: [PATCH 28/68] Gated tutorial gifs behind questions --- R/measure_centrality_closeness.R | 5 +- R/member_equivalence.R | 2 +- data-raw/build_tutorial_articles.R | 3 - inst/tutorials/netrics1/centrality.Rmd | 93 +++- inst/tutorials/netrics1/centrality.html | 697 ++++++++++++----------- inst/tutorials/netrics2/community.Rmd | 17 +- inst/tutorials/netrics2/community.html | 703 ++++++++++++------------ inst/tutorials/netrics3/position.Rmd | 14 +- inst/tutorials/netrics4/topology.Rmd | 5 +- vignettes/articles/centrality.Rmd | 80 ++- vignettes/articles/community.Rmd | 6 +- vignettes/articles/position.Rmd | 6 - vignettes/articles/topology.Rmd | 2 - 13 files changed, 861 insertions(+), 772 deletions(-) diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index 21772d3..9f21b7c 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -143,7 +143,10 @@ node_by_harmonic <- function(.data, normalized = TRUE, cutoff = -1, # directed network as though every tie ran both ways dists <- igraph::distances(manynet::as_igraph(.data), mode = direction) diag(dists) <- Inf # exclude self from own score - out <- rowSums(decay^(dists-1), na.rm = TRUE) # unreachable contribute 0 + contribs <- decay^(dists-1) + # zero these out explicitly, since e.g. 1^Inf is 1 rather than 0 + contribs[!is.finite(dists)] <- 0 # unreachable and self contribute 0 + out <- rowSums(contribs, na.rm = TRUE) if(normalized) out <- out/(manynet::net_nodes(.data)-1) meas <- "decay centrality" } diff --git a/R/member_equivalence.R b/R/member_equivalence.R index e8db2ed..56f03cf 100644 --- a/R/member_equivalence.R +++ b/R/member_equivalence.R @@ -258,7 +258,7 @@ node_in_block <- function(.data, k = 2L, out <- soln fit <- new_fit } - if(t %% 10) soln <- .strongPerturb(soln) + if(t %% 10 == 0) soln <- .strongPerturb(soln) } out <- make_node_member(out, .data) attr(out, "k") <- k diff --git a/data-raw/build_tutorial_articles.R b/data-raw/build_tutorial_articles.R index 36a3fbb..ebeebd2 100644 --- a/data-raw/build_tutorial_articles.R +++ b/data-raw/build_tutorial_articles.R @@ -183,9 +183,6 @@ build_article( "vignettes/articles/centrality.Rmd" ) -# The following tutorials have not yet been reworked into the branded flatly -# format, so the YAML/theme transform above would not apply cleanly. Uncomment -# each call once its tutorial has been reworked (see the replication phase). build_article( "inst/tutorials/netrics2/community.Rmd", "vignettes/articles/community.Rmd" diff --git a/inst/tutorials/netrics1/centrality.Rmd b/inst/tutorials/netrics1/centrality.Rmd index 4f45f39..30d00eb 100644 --- a/inst/tutorials/netrics1/centrality.Rmd +++ b/inst/tutorials/netrics1/centrality.Rmd @@ -100,8 +100,6 @@ This tutorial shows how to measure and map explore their `r gloss("distributions","distribution")`, and summarise the whole network's `r gloss("centralisation","centralization")`. -gif of a glowing circuit board captioned 'Central Processing Unit' - ::: {.callout} **Catching up**: This tutorial assumes you can already load or make a network in R, @@ -205,8 +203,6 @@ as they are assigned randomly from a pool of (American) first names. Direction · Strength -gif of a man saying 'I am a bit of a social butterfly' - ### Counting ties {#counting-ties} Let's start with calculating `r gloss("degree")`. @@ -268,7 +264,8 @@ question("In what ways are higher degree nodes more 'central'?", answer("They have more power than other nodes", message = "Not necessarily -- degree counts how many ties a node has, but power can depend on *who* those ties are to, not just how many there are."), answer("They are more active than other nodes", - correct = TRUE, message = learnr::random_praise()), + correct = TRUE, message = paste0(learnr::random_praise(), + "
\"gif")), answer("They would be located in the centre of the graph", message = "Not necessarily -- degree is about the number of ties, not where a node happens to be drawn; a high-degree node can sit anywhere in a layout."), answer("They would be able to control information flow in the network", @@ -409,7 +406,8 @@ question("Why would a node lying 'between' many other nodes be 'central'?", answer("They have more power than other nodes", message = "Not necessarily -- lying on many shortest paths gives a node the *opportunity* to broker, but that potential isn't automatically power."), answer("They would be able to control information flow in the network", - correct = TRUE, message = learnr::random_praise()), + correct = TRUE, message = paste0(learnr::random_praise(), + "
\"gif")), answer("They would be located in the centre of the graph", message = "Not necessarily -- betweenness is about lying on shortest paths between others, not about a node's position in a layout."), answer("They can distribute a message to all other nodes in the network most quickly", @@ -516,7 +514,9 @@ question("In what way is a node with the smallest sum of geodesic distances to a answer("They have more power than other nodes", message = "Not necessarily -- closeness reflects how efficiently a node can reach everyone else, which isn't the same as holding power over them."), answer("They can distribute a message to all other nodes in the network most quickly", - correct = TRUE, message = learnr::random_praise()), + correct = TRUE, + message = paste0(learnr::random_praise(), + "
\"gif")), answer("They would be located in the centre of the graph", message = "Not necessarily -- closeness is defined by geodesic distances, not by where a node is placed in a drawing."), answer("They would be able to control information flow in the network", @@ -617,7 +617,8 @@ question("Which of the following is true of eigenvector centrality?", answer("It is the only centrality measure that is correlated with power", message = "Not necessarily -- eigenvector centrality has no unique claim on power, and other measures can correlate with it too."), answer("It is a measure of influence, not activity", - correct = TRUE, message = learnr::random_praise()), + correct = TRUE, message = paste0(learnr::random_praise(), + "
\"gif")), answer("It always gives the highest score to the node with the highest degree", message = "Not necessarily -- because it weights ties by the importance of their neighbours, a lower-degree node connected to influential nodes can outrank a higher-degree node connected to peripheral ones."), answer("It is only meaningful in networks with nodes of similar degree", @@ -714,27 +715,75 @@ Linton Freeman's classic answer was that most of them boil down to just three ideas — degree, closeness, and betweenness — with everything else a variant; we add eigenvector as a fourth, giving the **four families** this tutorial is built around. +Another way of thinking about each of the centrality measures included +here is in terms of three questions. -Two questions organise the whole zoo, and locate each family within it. First: does the measure count what radiates _from_ a node — its own ties, its distances, its walks — or what passes _through_ it on the way between others? Borgatti and Everett ([2006](https://doi.org/10.1016/j.socnet.2005.11.005)) call the first **radial** and the second **medial**: degree, closeness, and eigenvector are radial; betweenness is medial. + Second: does it read only a node's immediate neighbourhood (**local**), or its place in the whole network (**global**)? Degree is local; the other three are global. -The four families are simply the most useful corners of that space, -and each variant you met earlier is a refinement or extension within its family. - -| Family | Reads a node's… | Built on | Answers the question | Well-suited when | +There is no medial-local family, so the four families are the useful corners +of that space rather than a full grid. +Note too that this is a dial rather than a switch: `node_by_reach()` takes a +`cutoff` that walks a measure from one end to the other, and at `cutoff = 1` +reach centrality simply _is_ degree. + +Third: what does the measure actually **traverse**? +Closeness and eigenvector are both radial and both global, +so the first two questions cannot tell them apart; +what separates them is that closeness travels by shortest paths +while eigenvector counts walks of every length. +The possibilities are nested by how much repetition they allow: + +> **walks** (anything goes) ⊇ **trails** (no tie used twice) ⊇ **paths** (no +> node visited twice) ⊇ **geodesics** (only the shortest paths) + +Two measures can traverse the same thing and still differ in how they _weight_ +what they find. Eigenvector, alpha and subgraph centrality count all walks +deterministically, discounting the longer ones; `node_by_pagerank()` and +`node_by_randomwalk()` traverse those same walks but weight them by the +probability that a random walker would take them. A few measures — +`node_by_information()` and `node_by_flow()` — sit outside this scheme +altogether, modelling current or maximum flow rather than any single route. + +| Family | Reads a node's… | Traverses | Answers the question | Well-suited when | |---|---|---|---|---| -| **Degree** | activity (radial, local) | direct ties | "Who is busiest, or most directly connected?" | only immediate ties matter, or influence spreads one step at a time; also the cheapest to compute on very large networks; outdegree associated with activity and indegree with popularity; tie weight and sign versions exist too | -| **Closeness** | reach (radial, global) | shortest-path distances to all others | "Who can reach — or be reached by — everyone else in the fewest steps?" | the network is connected and things travel by efficient routes; use `node_by_harmonic()` if it is disconnected | -| **Betweenness** | brokerage (medial, global) | shortest paths that pass through it | "Who sits between others, brokering, bridging, or bottlenecking flow?" | you care about gatekeeping, bridges between groups, or where flow is vulnerable; costly on huge networks (use a `cutoff`) | -| **Eigenvector** | standing (radial, global) | walks of all lengths, weighted by neighbours' scores | "Who is connected to other important, well-connected nodes?" | importance is recursive — prestige, status, influence; use `node_by_pagerank()` for directed or disconnected networks; use `node_by_power()` if influence depends on being connected to poorly-connected nodes | - -Reading across a row tells you what a family is _for_; -reading down the last column tells you which _kind of network_ each suits best. +| **Degree** | activity (radial, local) | direct ties only | "Who is busiest, or most directly connected?" | only immediate ties matter, or influence spreads one step at a time; also the cheapest to compute on very large networks; outdegree associated with activity and indegree with popularity; tie weight and sign versions exist too | +| **Closeness** | reach (radial, global) | geodesics — shortest paths to all others | "Who can reach — or be reached by — everyone else in the fewest steps?" | the network is connected and things travel by efficient routes; use `node_by_harmonic()` if it is disconnected | +| **Betweenness** | brokerage (medial, global) | geodesics — shortest paths passing through it | "Who sits between others, brokering, bridging, or bottlenecking flow?" | you care about gatekeeping, bridges between groups, or where flow is vulnerable; costly on huge networks (use a `cutoff`) | +| **Eigenvector** | standing (radial, global) | walks of every length, weighted by neighbours' scores | "Who is connected to other important, well-connected nodes?" | importance is recursive — prestige, status, influence; use `node_by_pagerank()` for directed or disconnected networks; use `node_by_power()` if influence depends on being connected to poorly-connected nodes | + +### Reading the numbers you get back + +Choosing a measure is half the job; knowing what its scores mean is the other half. +Every measure here reports how it was rescaled, and there are three possibilities worth telling apart. + +A **normalised** score has been divided by a _theoretical_ maximum — +the largest value the measure could take on a network of this size and shape. +Degree, closeness and betweenness all work this way, and because the yardstick +does not depend on the particular network you measured, their scores can be +compared _between_ networks: a normalised degree of 0.6 means the same thing in +either of two networks. + +A **scaled** score has been divided by the _observed_ maximum, the largest +value that actually turned up in this network. Eigenvector centrality works +this way, because an eigenvector is only defined up to a scalar multiple and so +has no absolute units to preserve. The consequence is easy to miss: the +top-scoring node always gets exactly 1.0, in every network, however central it +really is. Scaled scores rank nodes _within_ one network; they say nothing +across networks. + +A **proportion** — `node_by_pagerank()` is the example — divides each score by +their total, so the values sum to one and can be read as shares. + +And some measures are rescaled by none of these, because no sensible maximum exists: +`node_by_alpha()` can return negative values, and `node_by_subgraph()` grows exponentially. +There is no normalised version of every centrality measure, +and it is better to know that than to be handed a number between 0 and 1 that was never really bounded. ```{r whichcentQ, purl = FALSE} question("A rumour spreads step-by-step along ties, and you want to find the person who could seed it to reach everyone else in the fewest steps. Which family is the most natural first choice?", @@ -869,8 +918,6 @@ and the bridge to it runs through the _distribution_ of a nodal measure. ### Reading a distribution {#reading-a-distribution} -gif of a woman saying 'But I can't help it that I'm popular' - Rather than reduce a measure to a single summary number, we can look at its whole `r gloss("distribution")` across the nodes. `{autograph}` offers a way to get a pretty good first look at this, @@ -1085,8 +1132,6 @@ which can be compared to see which mode is more centralised with respect to the ## Free play -gif of a man saying 'I'm so tired of being so popular' - Choose another dataset included in `{manynet}` (browse them with `table_data()`). Name a plausible research question you could ask of the dataset relating to each of the four main centrality measures (degree, betweenness, closeness, eigenvector). diff --git a/inst/tutorials/netrics1/centrality.html b/inst/tutorials/netrics1/centrality.html index 2b97241..523e3a2 100644 --- a/inst/tutorials/netrics1/centrality.html +++ b/inst/tutorials/netrics1/centrality.html @@ -157,7 +157,6 @@

Today’s target

distributions , and summarise the whole network’s centralisation .

-

Catching up: This tutorial assumes you can already load or make a network in R, and draw @@ -287,7 +286,6 @@

Degree centrality

onclick="document.getElementById('section-degrees-of-direction').scrollIntoView({behavior:'auto',block:'start'});">Direction · Strength

-

Counting ties

Let’s start with calculating @@ -831,20 +829,45 @@

Which centrality?

for? Linton Freeman’s classic answer was that most of them boil down to just three ideas — degree, closeness, and betweenness — with everything else a variant; we add eigenvector as a fourth, giving the four -families this tutorial is built around.

-

Two questions organise the whole zoo, and locate each family within -it.1 First: does the measure count what -radiates from a node — its own ties, its distances, its walks — -or what passes through it on the way between others? Borgatti -and Everett call the first radial and the second -medial: degree, closeness, and eigenvector are radial; -betweenness is medial. Second: does it read only a node’s immediate -neighbourhood (local), or its place in the whole -network (global)? Degree is local; the other three are -global. The four families are simply the most useful corners of that -space, and each variant you met earlier is a refinement within its -family.

+families this tutorial is built around. Another way of thinking +about each of the centrality measures included here is in terms of three +questions.

+

First: does the measure count what radiates from a node — +its own ties, its distances, its walks — or what passes through +it on the way between others? Borgatti and Everett (2006) call the +first radial and the second medial: +degree, closeness, and eigenvector are radial; betweenness is +medial.

+

Second: does it read only a node’s immediate neighbourhood +(local), or its place in the whole network +(global)? Degree is local; the other three are global. +There is no medial-local family, so the four families are the useful +corners of that space rather than a full grid. Note too that this is a +dial rather than a switch: node_by_reach() takes a +cutoff that walks a measure from one end to the other, and +at cutoff = 1 reach centrality simply is +degree.

+

Third: what does the measure actually traverse? +Closeness and eigenvector are both radial and both global, so the first +two questions cannot tell them apart; what separates them is that +closeness travels by shortest paths while eigenvector counts walks of +every length. The possibilities are nested by how much repetition they +allow:

+
+

walks (anything goes) ⊇ trails (no +tie used twice) ⊇ paths (no node visited twice) ⊇ +geodesics (only the shortest paths)

+
+

Two measures can traverse the same thing and still differ in how they +weight what they find. Eigenvector, alpha and subgraph +centrality count all walks deterministically, discounting the longer +ones; node_by_pagerank() and +node_by_randomwalk() traverse those same walks but weight +them by the probability that a random walker would take them. A few +measures — node_by_information() and +node_by_flow() — sit outside this scheme altogether, +modelling current or maximum flow rather than any single route.

@@ -857,7 +880,7 @@

Which centrality?

- + @@ -866,15 +889,17 @@

Which centrality?

- + +also the cheapest to compute on very large networks; outdegree +associated with activity and indegree with popularity; tie weight and +sign versions exist too - + - + - + +node_by_pagerank() for directed or disconnected networks; +use node_by_power() if influence depends on being connected +to poorly-connected nodes
Family Reads a node’s…Built onTraverses Answers the question Well-suited when
Degree activity (radial, local)its direct tiesdirect ties only “Who is busiest, or most directly connected?” only immediate ties matter, or influence spreads one step at a time; -also the cheapest to compute on very large networks
Closeness reach (radial, global)shortest-path distances to all othersgeodesics — shortest paths to all others “Who can reach — or be reached by — everyone else in the fewest steps?” the network is connected and things travel by efficient routes; use @@ -883,7 +908,7 @@

Which centrality?

Betweenness brokerage (medial, global)shortest paths that pass through itgeodesics — shortest paths passing through it “Who sits between others, brokering, bridging, or bottlenecking flow?” you care about gatekeeping, bridges between groups, or where flow is @@ -892,21 +917,45 @@

Which centrality?

Eigenvector standing (radial, global)walks of all lengths, weighted by neighbours’ scoreswalks of every length, weighted by neighbours’ scores “Who is connected to other important, well-connected nodes?” importance is recursive — prestige, status, influence; use -node_by_pagerank() for directed or disconnected -networks
-

Reading across a row tells you what a family is for; reading -down the last column tells you which kind of network each suits -best. The families are usually positively correlated — central nodes -tend to be central on several measures at once — but the interesting -findings often lie where they disagree: the low-degree broker, -or the well-connected node that nonetheless reaches the rest of the -network slowly.

+
+

Reading the numbers you get back

+

Choosing a measure is half the job; knowing what its scores mean is +the other half. Every measure here reports how it was rescaled, and +there are three possibilities worth telling apart.

+

A normalised score has been divided by a +theoretical maximum — the largest value the measure could take +on a network of this size and shape. Degree, closeness and betweenness +all work this way, and because the yardstick does not depend on the +particular network you measured, their scores can be compared +between networks: a normalised degree of 0.6 means the same +thing in either of two networks.

+

A scaled score has been divided by the +observed maximum, the largest value that actually turned up in +this network. Eigenvector centrality works this way, because an +eigenvector is only defined up to a scalar multiple and so has no +absolute units to preserve. The consequence is easy to miss: the +top-scoring node always gets exactly 1.0, in every network, however +central it really is. Scaled scores rank nodes within one +network; they say nothing across networks.

+

A proportionnode_by_pagerank() is +the example — divides each score by their total, so the values sum to +one and can be read as shares.

+

And some measures are rescaled by none of these, because no sensible +maximum exists: node_by_alpha() can return negative values, +and node_by_subgraph() grows exponentially. There is no +normalised version of every centrality measure, and it is better to know +that than to be handed a number between 0 and 1 that was never really +bounded.

@@ -917,25 +966,12 @@

Which centrality?

Going further: -For the full landscape — including trail-, path-, and walk-based -measures beyond the four families here — see David Schoch’s introduction -to network centrality in R and the {netrankr} package, -which can even compare nodes without committing to a single -index.

+For the full landscape see David Schoch’s periodic table of +centrality and the {netrankr} package, which can even +compare nodes without committing to a single index.

Now let’s see how to spot the most central nodes at a glance.

-
-
-
    -
  1. This 2×2 framing is due to Borgatti and Everett -(2006), “A -graph-theoretic perspective on centrality”; David Schoch’s periodic table of -centrality and his {netrankr} package extend it to -organise the full set of indices.↩︎

  2. -
@@ -1049,7 +1085,6 @@

Centralisation

distribution of a nodal measure.

Reading a distribution

-

Rather than reduce a measure to a single summary number, we can look at its whole @@ -1185,11 +1220,8 @@

Measuring centralisation

Going further: For centralisation in -two-mode networks, two values are given (as a named -vector), one per mode. This is because normalisation typically depends -on the number of nodes in each mode, and those two counts are usually -different (asymmetric), so a single figure would not be comparable -across the modes.

+two-mode networks, mode_by_*() functions are +available to return two values, one per mode.

@@ -1243,15 +1275,16 @@

Comparing the measures

In brief: Swap node_ for net_ to move from a node’s -centrality to the whole network’s centralisation — a single -number (or one per mode, for two-mode networks) summarising how -unequally that centrality is distributed.

+centrality to the whole network’s centralisation, summarising +how unequally that centrality is distributed. For multimodal networks, +mode_by_*() returns one value per mode, which can be +compared to see which mode is more centralised with respect to the +other.

Free play

-

Choose another dataset included in {manynet} (browse them with table_data()). Name a plausible research question you could ask of the dataset relating to each of the four main @@ -1562,23 +1595,23 @@

Glossary

stocnet_theme("default") clear_glossary() learnr::random_phrases_add(language = "fr", - praise = c("C'est gnial!", + praise = c("C'est génial!", "Beau travail", "Excellent travail!", "Bravo!", "Super!", "Bien fait", - "Bien jou", + "Bien joué", "Tu l'as fait!", "Je savais que tu pouvais le faire.", - "a a l'air facile!", - "C'tait un travail de premire classe.", + "Ça a l'air facile!", + "C'était un travail de première classe.", "C'est ce que j'appelle un bon travail!"), encouragement = c("Bon effort", - "Vous l'avez presque matris!", - "a avance bien.", - "Continuez comme a.", - "Continuez travailler dur!", + "Vous l'avez presque maîtrisé!", + "Ça avance bien.", + "Continuez comme ça.", + "Continuez à travailler dur!", "Vous apprenez vite!", "Vous faites un excellent travail aujourd'hui.")) learnr::random_phrases_add(language = "en", @@ -1631,19 +1664,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "coercion", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -1693,19 +1726,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "addingnames", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -1755,19 +1788,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "degreesum", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -1815,11 +1848,11 @@

Glossary

@@ -1884,19 +1917,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "directing", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -1951,19 +1984,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "weighting", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2017,19 +2050,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "betcalc", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2071,29 +2104,29 @@

Glossary

@@ -2119,19 +2152,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "tiebet", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2185,19 +2218,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "induced", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2250,19 +2283,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "closecalc", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2304,29 +2337,29 @@

Glossary

@@ -2352,19 +2385,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "reach", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2415,19 +2448,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "eigencalc", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2470,28 +2503,28 @@

Glossary

@@ -2517,19 +2550,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "power", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2581,19 +2614,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "pagerank", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2637,19 +2670,19 @@

Glossary

@@ -2677,19 +2710,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "ggid", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2748,19 +2781,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "ggid_twomode", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2810,22 +2843,22 @@

Glossary

@@ -2851,19 +2884,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "distrib", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2907,21 +2940,21 @@

Glossary

@@ -2939,28 +2972,28 @@

Glossary

@@ -2986,19 +3019,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "otherdist", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3052,19 +3085,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "distcent", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3104,18 +3137,18 @@

Glossary

@@ -3145,19 +3178,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "centzn", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3211,19 +3244,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "multiplot", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3280,23 +3313,23 @@

Glossary

@@ -3360,19 +3393,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "freeplayend", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3409,12 +3442,12 @@

Glossary

diff --git a/inst/tutorials/netrics2/community.Rmd b/inst/tutorials/netrics2/community.Rmd index 047a919..d5f7e8c 100644 --- a/inst/tutorials/netrics2/community.Rmd +++ b/inst/tutorials/netrics2/community.Rmd @@ -417,8 +417,6 @@ and `r gloss("transitivity")`, where a directed two-path is likely to be shortened by an additional arc connecting the first and third nodes on that path. -gif of ah ha gotcha - ### Reciprocity {#reciprocity} First, let's calculate reciprocity in the task network. @@ -727,7 +725,7 @@ Remember the difference between weak and strong components? question("Weak components...", answer("don't care about tie direction when establishing components.", correct = TRUE, - message = "That's right -- a weak component treats every tie as if it were undirected, so two nodes are in the same weak component if a path connects them ignoring arrow direction."), + message = 'That\'s right -- a weak component treats every tie as if it were undirected, so two nodes are in the same weak component if a path connects them ignoring arrow direction.

gif of ah ha gotcha'), answer("care about tie direction when establishing components.", message = "That describes *strong* components, where every node must be reachable from every other following tie direction. Weak components ignore direction."), allow_retry = TRUE @@ -1012,7 +1010,18 @@ graphr(blogs, node_color = "Leaning") net_by_modularity(blogs, membership = node_attribute(blogs, "Leaning")) ``` -gif of Chevy Chase saying plot twist +```{r leaningQ, echo = FALSE, purl = FALSE} +question("How does the modularity of the blogs' declared leanings compare with that of the partition the algorithm found?", + answer("It is higher", + correct = TRUE, + message = 'Indeed -- the empirical attribute describes the tie pattern *better* than the algorithm\'s own bipartition.

gif of Chevy Chase saying plot twist'), + answer("It is lower", + message = "Have another look at the two numbers -- the leanings score the higher modularity here."), + answer("It is the same", + message = "Not quite -- the two memberships give noticeably different scores."), + allow_retry = TRUE +) +``` How interesting. Perhaps the partitioning algorithm is not the algorithm that maximises diff --git a/inst/tutorials/netrics2/community.html b/inst/tutorials/netrics2/community.html index 01805ac..6ce5be2 100644 --- a/inst/tutorials/netrics2/community.html +++ b/inst/tutorials/netrics2/community.html @@ -768,10 +768,9 @@

Closure in two-mode networks

of its attendees, and since cliques are perfectly transitive, the projections’ transitivity scores (0.93 and 0.83) are inflated well beyond anything the underlying behaviour requires. The -two-mode - -equivalency score (0.47) is the more honest summary of how -much the women’s attendance patterns actually reinforce one another.

+two-mode equivalence score (0.47) is the more honest summary of +how much the women’s attendance patterns actually reinforce one +another.

Try to explain in no more than a paragraph why projection can lead to misleading transitivity measures and what some consequences of this might be.

@@ -988,11 +987,8 @@

The giant component

network of political blogs, we might not think it is so undifferentiated. We might hypothesise that, despite the graphical presentation of a hairball, there is actually a -reasonable - -partition of the network into two - -factions .

+reasonable partition of the network into two +factions.

Finding a partition

@@ -1726,20 +1722,6 @@

Glossary

A dyad is a pair of nodes and the ties between them.
-Equivalency -
-
-Equivalency or reinforcement is the proportion of three-paths in a -two-mode network that are closed by a fourth tie into a four-cycle. -
-
-Faction -
-
-A faction is one of a fixed number of mutually exclusive groups into -which a network is partitioned. -
-
Giant
@@ -1779,13 +1761,6 @@

Glossary

A node or vertex is an entity or actor within a network.
-Partition -
-
-A partition is a division of the nodes in a network into mutually -exclusive groups. -
-
Projection
@@ -1854,23 +1829,23 @@

Glossary

stocnet_theme("default") clear_glossary() learnr::random_phrases_add(language = "fr", - praise = c("C'est gnial!", + praise = c("C'est génial!", "Beau travail", "Excellent travail!", "Bravo!", "Super!", "Bien fait", - "Bien jou", + "Bien joué", "Tu l'as fait!", "Je savais que tu pouvais le faire.", - "a a l'air facile!", - "C'tait un travail de premire classe.", + "Ça a l'air facile!", + "C'était un travail de première classe.", "C'est ce que j'appelle un bon travail!"), encouragement = c("Bon effort", - "Vous l'avez presque matris!", - "a avance bien.", - "Continuez comme a.", - "Continuez travailler dur!", + "Vous l'avez presque maîtrisé!", + "Ça avance bien.", + "Continuez comme ça.", + "Continuez à travailler dur!", "Vous apprenez vite!", "Vous faites un excellent travail aujourd'hui.")) learnr::random_phrases_add(language = "en", @@ -1925,19 +1900,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "data", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -1989,19 +1964,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "addingnames", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2053,19 +2028,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "separatingnets", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2112,15 +2087,15 @@

Glossary

@@ -2148,19 +2123,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "dens-explicit", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2216,19 +2191,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "dens", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2270,12 +2245,12 @@

Glossary

@@ -2303,19 +2278,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "recip", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2370,19 +2345,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "recip-explanation", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2437,19 +2412,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "trans", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2496,35 +2471,35 @@

Glossary

@@ -2550,19 +2525,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "setup-women", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2615,19 +2590,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "hardway", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2683,19 +2658,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "easyway", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2758,19 +2733,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "otherway", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2829,19 +2804,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "twomode-cohesion", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2893,35 +2868,35 @@

Glossary

@@ -2975,19 +2950,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "comp-no", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3034,19 +3009,19 @@

Glossary

@@ -3074,19 +3049,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "comp-memb", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3137,26 +3112,26 @@

Glossary

@@ -3182,19 +3157,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "blogsize", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3245,19 +3220,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "blogisolates", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3309,19 +3284,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "blogcomp", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3374,19 +3349,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "blogtogiant", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3439,19 +3414,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "bloggraph", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3508,19 +3483,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "blogmod", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3565,29 +3540,29 @@

Glossary

@@ -3613,19 +3588,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "blogmodassign", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3682,19 +3657,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "manip-fri", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3750,19 +3725,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "walk", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3822,19 +3797,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "walkplot", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3902,19 +3877,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "eb", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3973,19 +3948,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "ebplot", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -4050,19 +4025,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "fg", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -4117,22 +4092,22 @@

Glossary

@@ -4158,19 +4133,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "incomm", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -4217,51 +4192,51 @@

Glossary

@@ -4287,19 +4262,19 @@

Glossary

learnr:::store_exercise_cache(structure(list(label = "freeplay", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -4338,12 +4313,12 @@

Glossary

diff --git a/inst/tutorials/netrics3/position.Rmd b/inst/tutorials/netrics3/position.Rmd index 79300f7..f65da7b 100644 --- a/inst/tutorials/netrics3/position.Rmd +++ b/inst/tutorials/netrics3/position.Rmd @@ -883,8 +883,6 @@ profile for the whole network. Both need a _directed_ network and a Dendrograms · Choosing k -gif of rick multiplying - We now switch from asking about _individual_ positions (who is a broker?) to asking about _shared_ positions: which nodes play the same kind of role? Grouping nodes that occupy similar positions into classes is called finding @@ -901,7 +899,8 @@ Let's make sure the definition is clear before we compute it. question("Structural equivalence means identifying classes of nodes with...", answer("same/similar tie partners.", correct = TRUE, - message = learnr::random_praise()), + message = paste0(learnr::random_praise(), + '

gif of rick multiplying')), answer("same/similar pattern of ties.", message = "Close, but that is the definition for *regular* equivalence -- equivalent nodes tie to equivalent others, not necessarily the *same* others. We'll get to it."), answer("same/similar distance from all others.", @@ -1226,8 +1225,6 @@ and a cut-point choosing the number of clusters Summarising profiles · Plotting blockmodels -gif of mortys in a block parade - ### Summarising profiles {#summarising-profiles} Ok, so now we have a result from establishing nodes' membership in @@ -1251,8 +1248,6 @@ This aggregation of a network into the relationships _between_ classes is called `r gloss("blockmodelling","blockmodel")`, and it is the pay-off of all the equivalence work we just did. -gif of mortys learning about roles - One option that can be useful for characterising what the profile of ties (partners) is for each position/equivalence class is to use `summary()`. @@ -1380,9 +1375,10 @@ each role — is the interpretive skill this whole section is building towards. question("In a blockmodel plot sorted by class, what does an empty (white) off-diagonal block between class A (rows) and class B (columns) tell you?", answer("Members of class A rarely or never send ties to members of class B.", correct = TRUE, - message = learnr::random_praise()), + message = paste0(learnr::random_praise(), + '

gif of mortys in a block parade')), answer("Classes A and B are the same class.", - message = "Not quite -- an empty block is about the *absence of ties* from one class to another, not about the classes being identical. Identical classes wouldn't be drawn as two separate blocks."), + message = 'Not quite -- an empty block is about the *absence of ties* from one class to another, not about the classes being identical. Identical classes wouldn\'t be drawn as two separate blocks.

gif of mortys learning about roles'), answer("Every member of class A sends a tie to every member of class B.", message = "That would be a full, dark block -- the opposite of an empty one."), answer("Class A has no members.", diff --git a/inst/tutorials/netrics4/topology.Rmd b/inst/tutorials/netrics4/topology.Rmd index 870cace..38e0ba8 100644 --- a/inst/tutorials/netrics4/topology.Rmd +++ b/inst/tutorials/netrics4/topology.Rmd @@ -161,8 +161,6 @@ and that you can browse the full list with `table_data()`. Lattices · Rings - - In this practical, we're going to create/generate a number of ideal-typical network topologies and plot them. We'll first look at some deterministic algorithms for _creating_ networks @@ -281,7 +279,8 @@ or makes pockets of behaviour stable. ```{r lat-qa, echo=FALSE, purl = FALSE} question("Why are lattices considered highly clustered?", answer("Because neighbours are likely also neighbours of each other", - message = learnr::random_praise(), + message = paste0(learnr::random_praise(), + '

'), correct = TRUE), answer("Because all nodes are directly connected to each other", message = learnr::random_encouragement()), diff --git a/vignettes/articles/centrality.Rmd b/vignettes/articles/centrality.Rmd index 10066ac..abf8744 100644 --- a/vignettes/articles/centrality.Rmd +++ b/vignettes/articles/centrality.Rmd @@ -93,8 +93,6 @@ This tutorial shows how to measure and map explore their `r gloss("distributions","distribution")`, and summarise the whole network's `r gloss("centralisation","centralization")`. -gif of a glowing circuit board captioned 'Central Processing Unit' - ::: {.callout} **Catching up**: This tutorial assumes you can already load or make a network in R, @@ -166,8 +164,6 @@ as they are assigned randomly from a pool of (American) first names. Direction · Strength -gif of a man saying 'I am a bit of a social butterfly' - ### Counting ties {#counting-ties} Let's start with calculating `r gloss("degree")`. @@ -561,27 +557,75 @@ Linton Freeman's classic answer was that most of them boil down to just three ideas — degree, closeness, and betweenness — with everything else a variant; we add eigenvector as a fourth, giving the **four families** this tutorial is built around. +Another way of thinking about each of the centrality measures included +here is in terms of three questions. -Two questions organise the whole zoo, and locate each family within it. First: does the measure count what radiates _from_ a node — its own ties, its distances, its walks — or what passes _through_ it on the way between others? Borgatti and Everett ([2006](https://doi.org/10.1016/j.socnet.2005.11.005)) call the first **radial** and the second **medial**: degree, closeness, and eigenvector are radial; betweenness is medial. + Second: does it read only a node's immediate neighbourhood (**local**), or its place in the whole network (**global**)? Degree is local; the other three are global. -The four families are simply the most useful corners of that space, -and each variant you met earlier is a refinement or extension within its family. - -| Family | Reads a node's… | Built on | Answers the question | Well-suited when | +There is no medial-local family, so the four families are the useful corners +of that space rather than a full grid. +Note too that this is a dial rather than a switch: `node_by_reach()` takes a +`cutoff` that walks a measure from one end to the other, and at `cutoff = 1` +reach centrality simply _is_ degree. + +Third: what does the measure actually **traverse**? +Closeness and eigenvector are both radial and both global, +so the first two questions cannot tell them apart; +what separates them is that closeness travels by shortest paths +while eigenvector counts walks of every length. +The possibilities are nested by how much repetition they allow: + +> **walks** (anything goes) ⊇ **trails** (no tie used twice) ⊇ **paths** (no +> node visited twice) ⊇ **geodesics** (only the shortest paths) + +Two measures can traverse the same thing and still differ in how they _weight_ +what they find. Eigenvector, alpha and subgraph centrality count all walks +deterministically, discounting the longer ones; `node_by_pagerank()` and +`node_by_randomwalk()` traverse those same walks but weight them by the +probability that a random walker would take them. A few measures — +`node_by_information()` and `node_by_flow()` — sit outside this scheme +altogether, modelling current or maximum flow rather than any single route. + +| Family | Reads a node's… | Traverses | Answers the question | Well-suited when | |---|---|---|---|---| -| **Degree** | activity (radial, local) | direct ties | "Who is busiest, or most directly connected?" | only immediate ties matter, or influence spreads one step at a time; also the cheapest to compute on very large networks; outdegree associated with activity and indegree with popularity; tie weight and sign versions exist too | -| **Closeness** | reach (radial, global) | shortest-path distances to all others | "Who can reach — or be reached by — everyone else in the fewest steps?" | the network is connected and things travel by efficient routes; use `node_by_harmonic()` if it is disconnected | -| **Betweenness** | brokerage (medial, global) | shortest paths that pass through it | "Who sits between others, brokering, bridging, or bottlenecking flow?" | you care about gatekeeping, bridges between groups, or where flow is vulnerable; costly on huge networks (use a `cutoff`) | -| **Eigenvector** | standing (radial, global) | walks of all lengths, weighted by neighbours' scores | "Who is connected to other important, well-connected nodes?" | importance is recursive — prestige, status, influence; use `node_by_pagerank()` for directed or disconnected networks; use `node_by_power()` if influence depends on being connected to poorly-connected nodes | - -Reading across a row tells you what a family is _for_; -reading down the last column tells you which _kind of network_ each suits best. +| **Degree** | activity (radial, local) | direct ties only | "Who is busiest, or most directly connected?" | only immediate ties matter, or influence spreads one step at a time; also the cheapest to compute on very large networks; outdegree associated with activity and indegree with popularity; tie weight and sign versions exist too | +| **Closeness** | reach (radial, global) | geodesics — shortest paths to all others | "Who can reach — or be reached by — everyone else in the fewest steps?" | the network is connected and things travel by efficient routes; use `node_by_harmonic()` if it is disconnected | +| **Betweenness** | brokerage (medial, global) | geodesics — shortest paths passing through it | "Who sits between others, brokering, bridging, or bottlenecking flow?" | you care about gatekeeping, bridges between groups, or where flow is vulnerable; costly on huge networks (use a `cutoff`) | +| **Eigenvector** | standing (radial, global) | walks of every length, weighted by neighbours' scores | "Who is connected to other important, well-connected nodes?" | importance is recursive — prestige, status, influence; use `node_by_pagerank()` for directed or disconnected networks; use `node_by_power()` if influence depends on being connected to poorly-connected nodes | + +### Reading the numbers you get back + +Choosing a measure is half the job; knowing what its scores mean is the other half. +Every measure here reports how it was rescaled, and there are three possibilities worth telling apart. + +A **normalised** score has been divided by a _theoretical_ maximum — +the largest value the measure could take on a network of this size and shape. +Degree, closeness and betweenness all work this way, and because the yardstick +does not depend on the particular network you measured, their scores can be +compared _between_ networks: a normalised degree of 0.6 means the same thing in +either of two networks. + +A **scaled** score has been divided by the _observed_ maximum, the largest +value that actually turned up in this network. Eigenvector centrality works +this way, because an eigenvector is only defined up to a scalar multiple and so +has no absolute units to preserve. The consequence is easy to miss: the +top-scoring node always gets exactly 1.0, in every network, however central it +really is. Scaled scores rank nodes _within_ one network; they say nothing +across networks. + +A **proportion** — `node_by_pagerank()` is the example — divides each score by +their total, so the values sum to one and can be read as shares. + +And some measures are rescaled by none of these, because no sensible maximum exists: +`node_by_alpha()` can return negative values, and `node_by_subgraph()` grows exponentially. +There is no normalised version of every centrality measure, +and it is better to know that than to be handed a number between 0 and 1 that was never really bounded. ::: {.callout} **Try it yourself**: This section includes an interactive quiz in the live tutorial — run `run_tute()` at the R console to try it. @@ -656,8 +700,6 @@ and the bridge to it runs through the _distribution_ of a nodal measure. ### Reading a distribution {#reading-a-distribution} -gif of a woman saying 'But I can't help it that I'm popular' - Rather than reduce a measure to a single summary number, we can look at its whole `r gloss("distribution")` across the nodes. `{autograph}` offers a way to get a pretty good first look at this, @@ -764,8 +806,6 @@ which can be compared to see which mode is more centralised with respect to the ## Free play -gif of a man saying 'I'm so tired of being so popular' - Choose another dataset included in `{manynet}` (browse them with `table_data()`). Name a plausible research question you could ask of the dataset relating to each of the four main centrality measures (degree, betweenness, closeness, eigenvector). diff --git a/vignettes/articles/community.Rmd b/vignettes/articles/community.Rmd index 19914b5..0e35403 100644 --- a/vignettes/articles/community.Rmd +++ b/vignettes/articles/community.Rmd @@ -293,8 +293,6 @@ and `r gloss("transitivity")`, where a directed two-path is likely to be shortened by an additional arc connecting the first and third nodes on that path. -gif of ah ha gotcha - ### Reciprocity {#reciprocity} First, let's calculate reciprocity in the task network. @@ -676,7 +674,9 @@ graphr(blogs, node_color = "Leaning") net_by_modularity(blogs, membership = node_attribute(blogs, "Leaning")) ``` -gif of Chevy Chase saying plot twist +::: {.callout} +**Try it yourself**: This section includes an interactive quiz in the live tutorial — run `run_tute()` at the R console to try it. +::: How interesting. Perhaps the partitioning algorithm is not the algorithm that maximises diff --git a/vignettes/articles/position.Rmd b/vignettes/articles/position.Rmd index 5ca675b..f25760d 100644 --- a/vignettes/articles/position.Rmd +++ b/vignettes/articles/position.Rmd @@ -640,8 +640,6 @@ profile for the whole network. Both need a _directed_ network and a Dendrograms · Choosing k -gif of rick multiplying - We now switch from asking about _individual_ positions (who is a broker?) to asking about _shared_ positions: which nodes play the same kind of role? Grouping nodes that occupy similar positions into classes is called finding @@ -922,8 +920,6 @@ and a cut-point choosing the number of clusters Summarising profiles · Plotting blockmodels -gif of mortys in a block parade - ### Summarising profiles {#summarising-profiles} Ok, so now we have a result from establishing nodes' membership in @@ -947,8 +943,6 @@ This aggregation of a network into the relationships _between_ classes is called `r gloss("blockmodelling","blockmodel")`, and it is the pay-off of all the equivalence work we just did. -gif of mortys learning about roles - One option that can be useful for characterising what the profile of ties (partners) is for each position/equivalence class is to use `summary()`. diff --git a/vignettes/articles/topology.Rmd b/vignettes/articles/topology.Rmd index 31e8fdc..f830b5a 100644 --- a/vignettes/articles/topology.Rmd +++ b/vignettes/articles/topology.Rmd @@ -154,8 +154,6 @@ and that you can browse the full list with `table_data()`. Lattices · Rings - - In this practical, we're going to create/generate a number of ideal-typical network topologies and plot them. We'll first look at some deterministic algorithms for _creating_ networks From 78d9f4f322ec1684a9a9fecc432b21ba39679ca4 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 22:11:41 +0200 Subject: [PATCH 29/68] #major bump release --- DESCRIPTION | 2 +- NEWS.md | 2 +- R/member_equivalence.R | 2 +- cran-comments.md | 10 ++++------ man/member_equivalence.Rd | 2 +- 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index ebf3f00..dbebc58 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: netrics Title: Many Ways to Measure and Classify Membership for Networks, Nodes, and Ties -Version: 0.5.0 +Version: 1.0.0 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, diff --git a/NEWS.md b/NEWS.md index cbd0858..63c97a7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,4 +1,4 @@ -# netrics 0.5.0 +# netrics 1.0.0 ## Package diff --git a/R/member_equivalence.R b/R/member_equivalence.R index 56f03cf..dca857c 100644 --- a/R/member_equivalence.R +++ b/R/member_equivalence.R @@ -157,7 +157,7 @@ node_in_regular <- function(.data, #' closed neighbourhoods from those that bridge open ones, #' but it is not regular equivalence: see `node_in_regular()` for that. #' -#' This function was called `node_in_regular()` prior to version 0.5.0. +#' This function was called `node_in_regular()` prior to version 1.0.0. #' @examples #' (nme <- node_in_motif(ison_southern_women, cluster = "concor")) #' @export diff --git a/cran-comments.md b/cran-comments.md index 803edcb..12f70b0 100644 --- a/cran-comments.md +++ b/cran-comments.md @@ -1,12 +1,10 @@ ## Test environments -* local R installation, aarch64-apple-darwin23, R 4.6.0 -* macOS 15.7.7 (on Github), R 4.6.0 -* Microsoft Windows Server 2025 10.0.26100 (on Github), R 4.6.0 -* Ubuntu 24.04.4 (on Github), R 4.6.0 +* local R installation, macOS 26.5.2, aarch64-apple-darwin23, R 4.6.1 +* macOS 26.4 (on Github), R 4.6.1 +* Microsoft Windows Server 2025 10.0.26100 (on Github), R 4.6.1 +* Ubuntu 24.04.4 (on Github), R 4.6.1 ## R CMD check results 0 errors | 0 warnings | 0 notes - -- Updated manynet dependency to 0.3.1 to fix reverse dependency issue diff --git a/man/member_equivalence.Rd b/man/member_equivalence.Rd index 5f811f7..0d27fec 100644 --- a/man/member_equivalence.Rd +++ b/man/member_equivalence.Rd @@ -167,7 +167,7 @@ role. It is well suited to distinguishing nodes that sit in dense, closed neighbourhoods from those that bridge open ones, but it is not regular equivalence: see \code{node_in_regular()} for that. -This function was called \code{node_in_regular()} prior to version 0.5.0. +This function was called \code{node_in_regular()} prior to version 1.0.0. } \section{Direct blockmodelling}{ From 876dafa2815433e9aed192ac266fbb7ff790f7f0 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 9 Aug 2026 22:16:12 +0200 Subject: [PATCH 30/68] test finiteness explicitly, since `Inf <= Inf` is TRUE and would otherwise count the node itself --- R/measure_centrality_closeness.R | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index 9f21b7c..8b2758f 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -185,7 +185,9 @@ node_by_reach <- function(.data, normalized = TRUE, cutoff = 2){ out <- 1/tore } else out <- igraph::distances(manynet::as_igraph(.data)) diag(out) <- Inf # exclude self from own score - out <- rowSums(out <= cutoff) + # test finiteness explicitly, since `Inf <= Inf` is TRUE and would otherwise + # count the node itself, and unreachable nodes, when `cutoff = Inf` + out <- rowSums(is.finite(out) & out <= cutoff) if(normalized) out <- out/(manynet::net_nodes(.data)-1) make_node_measure(out, .data, measure = "reach centrality", range = `if`(normalized, c(0, 1), c(0, Inf)), From ed8879a98100181d4d537b3a8647c22b53e56f74 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Mon, 10 Aug 2026 08:57:03 +0200 Subject: [PATCH 31/68] Using roxygen2 v8.1 for startup speed --- DESCRIPTION | 2 +- NAMESPACE | 108 ++++++++++++++++++++++++++++------------------------ 2 files changed, 59 insertions(+), 51 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index dbebc58..732ddf4 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -44,4 +44,4 @@ Config/Needs/website: Config/testthat/parallel: true Config/testthat/edition: 3 Config/testthat/start-first: tutorials_netrics, measure_net, member_nodes, measure_nodes -Config/roxygen2/version: 8.0.0 +Config/roxygen2/version: 8.1.0 diff --git a/NAMESPACE b/NAMESPACE index f1a98a3..ba9190d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -201,53 +201,61 @@ export(tie_is_simmelian) export(tie_is_transitive) export(tie_is_triangular) export(tie_is_triplet) -importFrom(dplyr,filter) -importFrom(dplyr,group_by) -importFrom(dplyr,mutate) -importFrom(dplyr,select) -importFrom(igraph,V) -importFrom(igraph,adhesion) -importFrom(igraph,all_shortest_paths) -importFrom(igraph,alpha_centrality) -importFrom(igraph,articulation_points) -importFrom(igraph,assortativity_degree) -importFrom(igraph,cohesion) -importFrom(igraph,components) -importFrom(igraph,decompose) -importFrom(igraph,degree) -importFrom(igraph,delete_edges) -importFrom(igraph,delete_vertices) -importFrom(igraph,diameter) -importFrom(igraph,distances) -importFrom(igraph,edge_betweenness) -importFrom(igraph,edge_density) -importFrom(igraph,feedback_arc_set) -importFrom(igraph,fit_power_law) -importFrom(igraph,graph_from_incidence_matrix) -importFrom(igraph,is_bipartite) -importFrom(igraph,ivs_size) -importFrom(igraph,knn) -importFrom(igraph,largest_ivs) -importFrom(igraph,make_ego_graph) -importFrom(igraph,mean_distance) -importFrom(igraph,power_centrality) -importFrom(igraph,reciprocity) -importFrom(igraph,transitivity) -importFrom(igraph,triad_census) -importFrom(igraph,triangles) -importFrom(igraph,vcount) -importFrom(igraph,which_loop) -importFrom(igraph,which_multiple) -importFrom(igraph,which_mutual) -importFrom(manynet,as_igraph) -importFrom(manynet,is_complex) -importFrom(manynet,is_twomode) -importFrom(manynet,is_weighted) -importFrom(manynet,tie_weights) -importFrom(stats,as.dist) -importFrom(stats,coef) -importFrom(stats,complete.cases) -importFrom(stats,cor) -importFrom(stats,cutree) -importFrom(stats,hclust) -importFrom(stats,median) +importFrom(dplyr, + filter, + group_by, + mutate, + select +) +importFrom(igraph, + V, + adhesion, + all_shortest_paths, + alpha_centrality, + articulation_points, + assortativity_degree, + cohesion, + components, + decompose, + degree, + delete_edges, + delete_vertices, + diameter, + distances, + edge_betweenness, + edge_density, + feedback_arc_set, + fit_power_law, + graph_from_incidence_matrix, + is_bipartite, + ivs_size, + knn, + largest_ivs, + make_ego_graph, + mean_distance, + power_centrality, + reciprocity, + transitivity, + triad_census, + triangles, + vcount, + which_loop, + which_multiple, + which_mutual +) +importFrom(manynet, + as_igraph, + is_complex, + is_twomode, + is_weighted, + tie_weights +) +importFrom(stats, + as.dist, + coef, + complete.cases, + cor, + cutree, + hclust, + median +) From 32e80633a49d8ffd93082747befaffad36572ce0 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 13 Aug 2026 15:14:52 +0200 Subject: [PATCH 32/68] Updated CONTRIBUTING to be clearer about documentation conventions --- .github/CONTRIBUTING.md | 296 ++++++++++++++++++++++++++-------------- NEWS.md | 3 +- 2 files changed, 198 insertions(+), 101 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 650f937..f696daf 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -4,23 +4,9 @@ Contributions to `netrics`, whether in the form of issue identification, bug fixes, new code or documentation are encouraged and welcome. -## Aims - -Here is some things that Guy Kawasaki, Silicon Valley venture capitalist, -learned from Steve Jobs: - -- "Experts" are clueless. Especially self-declared ones. -- Customers cannot tell you what they need. They can help with evolution, but not revolution. -- Biggest challenges beget the best work. -- Design counts. Users will see the skin/UI of your product, not the great algorithms. -- Big graphics, big fonts. -- Jump curves---do things 10 times better, not 10 percent. -- All that truly matters is whether something works or doesn't work. Open or close, iPhone or Android, car or train, doesn't matter---make -it work. -- "Value" is different from "price". There is a class of people who do care about value. Ease of use -> less support costs. You have to create a unique and valuable product as an engineer. -- Real CEOs can demo. If you can't demo your own product, then quit. -- Real entrepreneurs ship, not slip. -- Some things need to be believed to be seen. +Please note that the `netrics` project is released with a +[Contributor Code of Conduct](CODE_OF_CONDUCT.md). +By contributing to this project, you agree to abide by its terms. ## Git @@ -40,6 +26,42 @@ The GitHub page allows to access the issues assigned to you and check the commit You can also access the documents in the repository, although this won't be necessary after you have cloned it on your computer via Fork. +### Cloning + +Once you have downloaded Fork, the first thing you have to do is to +clone the remote repository on your computer. +Before cloning, you will be able to choose on which `branch` you want to work: +develop or main. + +### Pull + +This command allows you to `pull` changes from the remote repository to your local repository on Sourcetree. +Make sure you do that before starting working on your files so you have the newest versions. +When pulling, make sure you choose master or develop, +depending on the branch you decided to work with. +Once you pulled, you have now all the new commits and files and +you can start working on your assigned tasks. +Note that you can access and open the files either from the Finder or from Fork. +Some documents might be stored using Large File Storage (LFS) to save space on the repository. + +### Commit and Push + +Once you have made modifications on a file and saved them, it will appear in your `commit` window. +Here you can control one last time your file, write the commit message with the +issue reference (see below) and commit. +Once your commit is ready, you can `push` them to the origin/main repository. +Note that you can click the "push immediately" box in the commit window +if you don't want to do it in two steps. +If you are working on a separate branch, +it is important to select this branch when pushing to origin/main. + +### Branching and CI + +- `main` is the release branch; `develop` is the working branch (clone/work on `develop`). +- PRs into `main` trigger [prchecks.yml](workflows/prchecks.yml): R CMD check (macOS/Windows/Linux), binary build, codecov, lintr, spell check, a check that the tutorial articles are in sync with the tutorials, and PR metadata checks (DESCRIPTION version bump, PR title/description conventions). +- Merges/pushes to `main` trigger [pushrelease.yml](workflows/pushrelease.yml): check, auto-bump version tag, GitHub release with binaries, then pkgdown site deploy. +- Commits should reference an existing GitHub issue number (`#123`), see below. + ## Style In terms of style, we are aiming for pleasant predictability in terms of user experience. @@ -68,9 +90,16 @@ Run these from an R console with the working directory set to the package root ( - Full package check (mirrors CI): `devtools::check()` or `rcmdcheck::rcmdcheck()` - Lint: `lintr::lint_package()` - Spell check: `spelling::spell_check_package()` +- Code coverage: `covr::package_coverage()` +- Rebuild `README.md` from `README.Rmd`: `devtools::build_readme()` - Build pkgdown site locally: `pkgdown::build_site()` There is no non-R build system — no package.json/Makefile. +Roxygen is configured with `markdown = TRUE`; +`NAMESPACE` and all `man/*.Rd` files are generated — never hand-edit them. +Some other files are generated rather than edited directly — `README.md` and the tutorial +articles in `vignettes/articles/`. +See [README and website](#readme-and-website) below for which source each is built from. ### Function family naming (the core convention) @@ -151,86 +180,153 @@ Tests in `tests/testthat/` mirror the `R/` files (e.g. `test-measure_centrality. `testthat` edition 3 with parallel execution is configured in `DESCRIPTION` (`Config/testthat/parallel: true`). `Config/testthat/start-first` prioritizes `tutorials_netrics, measure_net, member_nodes, measure_nodes`. -### Branching and CI - -- `main` is the release branch; `develop` is the working branch (clone/work on `develop`). -- PRs into `main` trigger [prchecks.yml](workflows/prchecks.yml): R CMD check (macOS/Windows/Linux), binary build, codecov, lintr, spell check, and PR metadata checks (DESCRIPTION version bump, PR title/description conventions). -- Merges/pushes to `main` trigger [pushrelease.yml](workflows/pushrelease.yml): check, auto-bump version tag, GitHub release with binaries, then pkgdown site deploy. -- Commits should reference an existing GitHub issue number (`#123`), see below. - -## Fork - -### Cloning -Once you have downloaded Fork, the first thing you have to do is to -clone the remote repository on your computer. -Before cloning, you will be able to choose on which `branch` you want to work: -develop or main. - -### Pull -This command allows you to `pull` changes from the remote repository to your local repository on Sourcetree. -Make sure you do that before starting working on your files so you have the newest versions. -When pulling, make sure you choose master or develop, -depending on the branch you decided to work with. -Once you pulled, you have now all the new commits and files and -you can start working on your assigned tasks. -Note that you can access and open the files either from the Finder or from Fork. -Some documents might be stored using Large File Storage (LFS) to save space on the repository. - -### Commit and Push - -Once you have made modifications on a file and saved them, it will appear in your `commit` window. -Here you can control one last time your file, write the commit message with the -issue reference (see below) and commit. -Once your commit is ready, you can `push` them to the origin/main repository. -Note that you can click the "push immediately" box in the commit window -if you don't want to do it in two steps. -If you are working on a separate branch, -it is important to select this branch when pushing to origin/main. - -## Issues and tests - -Please use the issues tracker on GitHub to identify any function-related issues. -You can use these issues to track progress on the issue and -to comment or continue a conversation on that issue. -Currently issue tracking is only open to those involved in the project. - -The most useful issues are ones that precisely identify an error, -or propose a test that should pass but instead fails. -This package uses the `testthat` package for testing functions. -Please see the [testthat website](https://testthat.r-lib.org) for more details. - -## Bug fixing or adding new code - -Independent or assigned code contributions are most welcome. -When writing new code, please follow -[standard R guidelines](https://www.r-bloggers.com/🖊-r-coding-style-guide/). -It can help to use packages such as `lintr`, `goodpractice` and `formatR` -to ensure these are followed. - -Currently, commits can only be pushed to GitHub where they reference an existing issue. -If no issue exists for the code you have developed, please add an issue first before pushing. -Once the issue exists, you will need to mention the issue number (preceded by a hash symbol: #) -in the commit description: - -` Resolved #31 by adding a new function that does things, also updated documentation ` - -Where the issue hash (i.e. #31) is preceded by -`resolve`, `resolves`, `resolved`, `close`, `closes`, `closed`, `fix`, `fixes`, or `fixed` -(capitalised or not), -Github will automatically updated the status of the issue(s) mentioned. - -Our current syntactical standard is to mention the issue first and then -provide a short description of what the committed changes do -in relation to that issue. -Any ancillary changes can be mentioned after a comma. - -## Documentation - -A final way of contributing to the package is in developing the -vignettes/articles that illustrate the value added in the package. -Please contact me with any proposals here. - -Please note that the `netrics` project is released with a -[Contributor Code of Conduct](CODE_OF_CONDUCT.md). -By contributing to this project, you agree to abide by its terms. - +### Console messaging + +All user-facing messages go through the `snet_*()` wrappers exported by `{manynet}`, +rather than base `message()`/`stop()`/`warning()` or `{cli}` calls directly: + +| Wrapper | Use for | +|---|---| +| `snet_abort()` | errors: the function cannot proceed | +| `snet_warn()` | the function proceeds, but the user should know something | +| `snet_info()` | notable information about what was done, e.g. a defaulted argument or the method dispatched to | +| `snet_minor_info()` | incidental detail | +| `snet_success()` | confirmation that a requested operation completed | +| `snet_prompt()` | interactive questions to the user | +| `snet_unavailable()` | not-yet-implemented features | +| `snet_progress_step()`, `snet_progress_along()`, `snet_progress_seq()`, `snet_progress_nodes()` | progress reporting in longer-running loops | + +Every wrapper except `snet_abort()` (and `snet_prompt()`) is silenced by +`options(snet_verbosity = "quiet")`, which is the *default* — +so informational output must never be load-bearing, +and errors must carry everything the user needs to act. +Users opt in with e.g. `options(snet_verbosity = "verbose")`. + +These wrappers pass their input to `{cli}`, so: + +- Braces interpolate, replacing `paste()`: `snet_abort("{.val {unknown}} is not a recognised method.")`. +- Use `{cli}` inline classes to mark up what you refer to — `{.fn}` for functions, + `{.arg}`/`{.var}` for arguments and variables, `{.val}` for values, + `{.url}` for links — so that styling stays consistent across the ecosystem. +- Use `{cli}`'s pluralisation rather than hand-written branches: + `snet_warn("Node{?s} {.val {missing}} {?was/were} dropped.")`. +- Multiple strings can be passed as separate arguments for multiline messages. + +Messages, warnings, and errors should be written in a way that is useful for new and advanced users alike. +This might include listing likely causes, mentioning objects or variables explicitly, +and indicating next actions clearly. +Prefer "`{.arg alpha}` must be a single number between 0 and 1" over "invalid input". +Functions that dispatch on a character argument should name the method they chose, +e.g. `manynet::snet_info("...using {.fn regularity_{regularity}}.")`, +which surfaces the method-helper convention above at run time. + +### Documentation + +Roxygen is configured with `markdown = TRUE`; +`NAMESPACE` and all `man/*.Rd` files are generated — never hand-edit them. +Run `devtools::document()` after changing any roxygen comment. + +- Reuse the shared `@template` fragments in `man-roxygen/` (e.g. `param_data.R`, + `node_measure.R`, `param_norm.R`) instead of re-writing standard `@param`/`@returns` docs. + If you find yourself writing the same `@param` twice, add a template. + Indeed, prefer defining fewer arguments, so if alpha and beta are both decays, + just use `decay=` as the argument. +- Related functions share one roxygen block via `@name`/`@rdname`, + matching the file organisation above. +- Every exported function needs a runnable `@examples` block: + examples are run by R CMD check, and they are also the fastest documentation for users. + Prefer the bundled `ison_*`/`fict_*` networks over ad hoc constructions, + unless they take too long to run. +- Cite the source of a measure with `@references` in the ecosystem's format + (authors, year, title, journal, and `\doi{}` where available), + so that users can trace an implementation back to its definition. +- Documented behaviour and implemented behaviour must agree. + Several past bugs were documentation claiming a default or a normalisation that the code did not apply, + so when you change a default, search the roxygen and templates for it too. + +### README and website + +The README offers a landing page for new users, both on the GitHub repository +as well as on the website. +As such, it should make a compelling case for the value added of the package, +and not drift out of date. +Note that `README.md` is generated from `README.Rmd` — edit `README.Rmd` and re-knit +(`devtools::build_readme()`), never edit `README.md` directly. + +The website is created by pkgdown from [pkgdown/_pkgdown.yml](../pkgdown/_pkgdown.yml), +and is deployed automatically when changes reach `main`. +Please make sure that the pkgdown website will build correctly: +run `pkgdown::build_site()` locally before opening a PR. +The most common failure is a new exported function that is not picked up under the +function overview (the `reference:` section of `_pkgdown.yml`) — +pkgdown requires *every* exported topic to appear there exactly once, or it will not build. +Where possible, add functions to an existing subtitle's `starts_with()`/`contains()` pattern +(e.g. a new `node_is_*()` mark or `node_in_*()` membership needs no change), +and only list the topic explicitly where it does not fit a pattern. +These `reference:` titles are also the headings used in `NEWS.md` (see below), +so keep the two in step. + +The static pkgdown versions of the `{learnr}` tutorials, `vignettes/articles/*.Rmd`, +are generated rather than edited directly: +they are built from `inst/tutorials/*/*.Rmd` by +[data-raw/build_tutorial_articles.R](../data-raw/build_tutorial_articles.R). +After editing a tutorial, re-run that script and commit the regenerated articles; +CI checks that the two are in sync. +New tutorials also need an entry under `articles:` in `_pkgdown.yml`. + +### `NEWS.md` conventions + +`NEWS.md` groups each version's changes under `##` headings that mirror the website +function overview (`pkgdown/_pkgdown.yml` `reference:` titles). +Lead with `## Package` (package-wide/website/infrastructure changes), +then the function families in overview order: +`## Marks`, `## Measures`, `## Memberships`, `## Motifs`, `## Methods`. +Put `## Tutorials` and any `## Data` section at the end. +Each heading appears at most once per version. + +Start each bullet with a verb matching the change type: + +- `Added ...` — new functionality +- `Fixed ...` — bug fixes; if it relates to a GitHub issue, suffix with `(closing #123)` +- `Renamed ... to ...` — function or data name migrations +- `Improved ...` — functional updates to existing behaviour +- `Updated ...` — documentation changes + +If a cited GitHub issue was **not** authored by @jhollway, thank the author with an +`@`-tag in the bullet. +Cluster related changes (e.g. several fixes to the same function, or sub-points of one +feature) as indented sub-bullets under a lead bullet, to improve readability. + +#### Writing the bullets + +`NEWS.md` is read by users scanning for what changed, not by reviewers reading prose, +so each bullet is a headline rather than a sentence, +so avoid over-punctuation or over-explanation. +Details can be added to the function documentation, if necessary. + +- No full stop at the end of a bullet +- One clause where possible, and at most one comma + - If a bullet needs a second clause to be understood, use a sub-bullet +- Name the function or object in backticks and say what changed to it, + dropping scaffolding like "This change ...", "In order to ...", or "as part of an effort to" +- Keep the *what*, and add the *why* only where the behaviour would otherwise look arbitrary +- No trailing rationale, no restating the same change twice in different words, + and no marketing adjectives such as "comprehensive" or "robust" + +For example, instead of: + +> Fixed a bug where, in some cases, `node_by_reach()` was counting the node itself, +> which meant that normalised scores could exceed 1. + +write: + +> Fixed `node_by_reach()` counting the node itself so normalised scores no longer exceed 1 + +and instead of: + +> Added a new function, `net_by_compactness()`, which is a useful measure that +> calculates the average closeness of all pairs of nodes in the network. + +write: + +> Added `net_by_compactness()` for the average closeness of all pairs of nodes diff --git a/NEWS.md b/NEWS.md index 63c97a7..6e43365 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,9 +4,10 @@ - Removed the CRAN version check from `.onAttach()` making `library(netrics)` faster to attach - It now runs once, for the whole stack, in `{migraph}`, where it is cached and checks GitHub as well as CRAN -- Updated GitHub Actions workflows to latest major action versions - Fixed release workflow referring to `actions/actions/checkout`, a doubled path segment that would have failed every step using it - Added `param_cutoff` roxygen template, correctly documenting geodesic cutoff for six functions +- Updated GitHub Actions workflows to latest major action versions +- Updated CONTRIBUTING to be clearer about documentation conventions ## Measures From 7a0836818c9301298cc57d25e2137a0817ddfd90 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 13 Aug 2026 15:58:16 +0200 Subject: [PATCH 33/68] Improved `make_*_measure()` to record what was computed so results can be read without the script/manual --- NEWS.md | 23 +++++++++++------------ R/class_metrics.R | 15 ++++++++++++--- man-roxygen/mode_measure.R | 7 +++++++ man-roxygen/net_measure.R | 7 +++++++ man-roxygen/node_measure.R | 7 +++++++ man-roxygen/tie_measure.R | 7 +++++++ 6 files changed, 51 insertions(+), 15 deletions(-) diff --git a/NEWS.md b/NEWS.md index 6e43365..a87d975 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,23 +2,22 @@ ## Package -- Removed the CRAN version check from `.onAttach()` making `library(netrics)` faster to attach - - It now runs once, for the whole stack, in `{migraph}`, where it is cached and checks GitHub as well as CRAN -- Fixed release workflow referring to `actions/actions/checkout`, a doubled path segment that would have failed every step using it +- Removed CRAN version check from `.onAttach()` making `library(netrics)` faster to attach +- Fixed release workflow referring to `actions/actions/checkout`; the doubled path segment would have failed every step using it - Added `param_cutoff` roxygen template, correctly documenting geodesic cutoff for six functions +- Added `param_decay` roxygen template, correctly documenting decay parameter - Updated GitHub Actions workflows to latest major action versions -- Updated CONTRIBUTING to be clearer about documentation conventions +- Updated CONTRIBUTING to be clearer about documentation, website and NEWS conventions ## Measures -- Improved measures to record what they computed, so results can be interpreted without - consulting the manual. `make_*_measure()` attaches three attributes: - - `measure`, name of measure actually calculated, - e.g. `node_by_degree()` reports "strength centrality" on a weighted network with `alpha = 1` - - `normalization`, one of `"normalized"`, `"scaled"`, `"proportion"`, or `"none"` - - `range`, theoretical range of the returned values - - These are additive: measures that do not set them behave exactly as before. - Surfacing them when printing is a companion change in `{manynet}`. +- Improved `make_*_measure()` to record what was computed so results can be read without the script/manual + - `measure`, the measure actually calculated, e.g. `node_by_degree()` reports "strength centrality" on a weighted network + - `normalization`, one of `"normalized"`, `"scaled"`, `"proportional"` or `"none"` + - `range`, the theoretical range of the returned values + - `variant`, which variant was computed where a measure offers a choice, e.g. `net_by_reciprocity()` reports "ratio" when asked for the ratio + - Measures that set none of these behave exactly as before + - Printing them is a companion change in `{manynet}` - Improved specificity of arguments, separating normalising from scaling - Renamed `scale` argument to `scaled`, the old spelling still works but warns - Added family-wide contract test sweeping every node-level centrality: diff --git a/R/class_metrics.R b/R/class_metrics.R index 957337d..ab5de1d 100644 --- a/R/class_metrics.R +++ b/R/class_metrics.R @@ -30,21 +30,30 @@ make_tie_mark <- function(out, .data) { # across different networks # "scaled" divided by the observed maximum, so the top node is always # exactly 1 and values rank nodes within one network only -# "proportion" shares of a fixed total, summing to 1 +# "proportional" shares of a fixed total, summing to 1 # "none" raw values on the measure's own scale -NORMALIZATIONS <- c("normalized", "scaled", "proportion", "none") +NORMALIZATIONS <- c("normalized", "scaled", "proportional", "none") + +# Where a measure offers a choice between several ways of counting the same +# thing, `variant` records which one ran. It is orthogonal to `normalization`: +# the first says *which* quantity was computed, the second *how* its values +# were rescaled, and a measure may meaningfully declare both, as +# `net_by_smallworld()` does in reporting the "SWI" variant as normalised. +# Unlike `NORMALIZATIONS` there is no fixed vocabulary to match against, since +# each family names its own variants. # Attaches the interpretive metadata shared by all measure classes. # Each argument is optional; absent metadata is simply not set, so measures # that do not (yet) declare it behave exactly as they did before. set_measure_attributes <- function(out, measure = NULL, range = NULL, - normalization = NULL) { + normalization = NULL, variant = NULL) { if(!is.null(measure)) attr(out, "measure") <- measure if(!is.null(range)) attr(out, "range") <- range if(!is.null(normalization)) { normalization <- match.arg(normalization, NORMALIZATIONS) attr(out, "normalization") <- normalization } + if(!is.null(variant)) attr(out, "variant") <- as.character(variant)[1] out } diff --git a/man-roxygen/mode_measure.R b/man-roxygen/mode_measure.R index 90e413c..cdc05d0 100644 --- a/man-roxygen/mode_measure.R +++ b/man-roxygen/mode_measure.R @@ -2,3 +2,10 @@ #' @returns #' A `mode_measure` numeric vector of length two, #' giving one centralization score per mode. +#' +#' The object also carries the `measure` it computed, the `range` its values +#' can fall within, and whether and how those values were `normalized`. +#' These are shown as a one-line header when the object is printed. +#' Where a measure offers a choice between several ways of counting the +#' same thing, it also carries the `variant` it used. +#' All can be retrieved with `attr()`. diff --git a/man-roxygen/net_measure.R b/man-roxygen/net_measure.R index 720aef7..ee13b53 100644 --- a/man-roxygen/net_measure.R +++ b/man-roxygen/net_measure.R @@ -1,3 +1,10 @@ #' @family measures #' @returns #' A `network_measure` numeric score. +#' +#' The object also carries the `measure` it computed, the `range` its values +#' can fall within, and whether and how those values were `normalized`. +#' These are shown as a one-line header when the object is printed. +#' Where a measure offers a choice between several ways of counting the +#' same thing, it also carries the `variant` it used. +#' All can be retrieved with `attr()`. diff --git a/man-roxygen/node_measure.R b/man-roxygen/node_measure.R index 3f93514..9d2eed4 100644 --- a/man-roxygen/node_measure.R +++ b/man-roxygen/node_measure.R @@ -5,3 +5,10 @@ #' providing the scores for each node. #' If the network is labelled, #' then the scores will be labelled with the nodes' names. +#' +#' The object also carries the `measure` it computed, the `range` its values +#' can fall within, and whether and how those values were `normalized`. +#' These are shown as a one-line header when the object is printed. +#' Where a measure offers a choice between several ways of counting the +#' same thing, it also carries the `variant` it used. +#' All can be retrieved with `attr()`. diff --git a/man-roxygen/tie_measure.R b/man-roxygen/tie_measure.R index b02204d..d463db5 100644 --- a/man-roxygen/tie_measure.R +++ b/man-roxygen/tie_measure.R @@ -5,3 +5,10 @@ #' providing the scores for each tie. #' If the network is labelled, #' then the scores will be labelled with the ties' adjacent nodes' names. +#' +#' The object also carries the `measure` it computed, the `range` its values +#' can fall within, and whether and how those values were `normalized`. +#' These are shown as a one-line header when the object is printed. +#' Where a measure offers a choice between several ways of counting the +#' same thing, it also carries the `variant` it used. +#' All can be retrieved with `attr()`. From 383b6e33a95ce9994caa5c2a59966c267d25759b Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 13 Aug 2026 19:41:42 +0200 Subject: [PATCH 34/68] Updated to_blocks() to to_blockmodel() --- DESCRIPTION | 2 +- inst/tutorials/netrics3/position.Rmd | 10 +++++----- inst/tutorials/netrics3/position.html | 18 +++++++++--------- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 732ddf4..f3d1202 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -15,7 +15,7 @@ Encoding: UTF-8 LazyData: true Depends: R (>= 4.1.0), - manynet (>= 2.1.2) + manynet (>= 2.3.0) Imports: dplyr, igraph (>= 2.1.0) diff --git a/inst/tutorials/netrics3/position.Rmd b/inst/tutorials/netrics3/position.Rmd index f65da7b..89b7c7e 100644 --- a/inst/tutorials/netrics3/position.Rmd +++ b/inst/tutorials/netrics3/position.Rmd @@ -157,7 +157,7 @@ By the end of this tutorial, you should be able to: - [ ]   Partition a network into equivalent classes, and understand the census, clustering, and _k_-selection choices behind it - [ ]   Read a dendrogram and a blockmodel, and justify a choice of _k_ - [ ]   Score how well a partition fits with `net_by_inconsistency()`, and search for one directly with `node_in_block()` -- [ ]   Contract a network into a reduced graph of positions with `to_blocks()` +- [ ]   Contract a network into a reduced graph of positions with `to_blockmodel()` **Choose your own data**: The worked examples below use `ison_algebra`, a multiplex network of interactions in an algebra class @@ -1494,7 +1494,7 @@ any within-class ties will end up becoming loops and thus the network will be co ### From blocks to ties {#from-blocks-to-ties} -`to_blocks()` carries out this contraction: +`to_blockmodel()` carries out this contraction: pass it the network and a membership vector, and it returns a matrix with one row and column per _class_, where each cell holds the average tie (weight) from one class to another. @@ -1502,7 +1502,7 @@ where each cell holds the average tie (weight) from one class to another. **Contract the algebra network into its four structural-equivalence blocks.** ```{r structblock, exercise = TRUE, exercise.setup = "varyclust", warning=FALSE} -(bm <- to_blocks(alge, node_in_structural(alge))) +(bm <- to_blockmodel(alge, node_in_structural(alge))) ``` Notice how this is just a compact, numerical version of the blockmodel plot @@ -1536,7 +1536,7 @@ a network of sixteen individuals compressed into a comprehensible structure of four roles. ::: {.callout} -**In brief**: `to_blocks()` contracts a network into its classes, +**In brief**: `to_blockmodel()` contracts a network into its classes, yielding a `r gloss("reduced graph","reduced")` whose nodes are positions and whose ties (including loops) are the average ties within and between classes — a role-level summary of the whole network. @@ -1777,7 +1777,7 @@ Along the way, you have learned to use these functions: | `net_by_inconsistency()` | scores how far a partition's blocks are from ideal (0 is perfect); `blocks` sets which ideals | | `node_in_block()` | searches directly for the partition that best fits an ideal block structure | | `summary(census, membership = )` | averages each class's census profile | -| `to_blocks()` | contracts a network into a reduced graph of positions | +| `to_blockmodel()` | contracts a network into a reduced graph of positions | | `graphr(..., node_color = , node_size = )` | maps memberships or measures onto the graph | When you are ready, continue with the other `{netrics}` tutorials — diff --git a/inst/tutorials/netrics3/position.html b/inst/tutorials/netrics3/position.html index 0d014a6..b371f8d 100644 --- a/inst/tutorials/netrics3/position.html +++ b/inst/tutorials/netrics3/position.html @@ -217,7 +217,7 @@

Aims

  • +graph of positions with to_blockmodel()

    Choose your own data: The worked examples below use ison_algebra, a multiplex network of interactions in an @@ -1310,7 +1310,7 @@

    Reduced graphs

    and thus the network will be complex.

    From blocks to ties

    -

    to_blocks() carries out this contraction: pass it the +

    to_blockmodel() carries out this contraction: pass it the network and a membership vector, and it returns a matrix with one row and column per class, where each cell holds the average tie (weight) from one class to another.

    @@ -1319,7 +1319,7 @@

    From blocks to ties

    -
    (bm <- to_blocks(alge, node_in_structural(alge)))
    +
    (bm <- to_blockmodel(alge, node_in_structural(alge)))

    Notice how this is just a compact, numerical version of the @@ -1352,7 +1352,7 @@

    Naming positions

    roles.

    In brief: -to_blocks() contracts a network into its classes, yielding +to_blockmodel() contracts a network into its classes, yielding a reduced graph whose nodes are positions and whose ties @@ -1656,7 +1656,7 @@

    Summary

    averages each class’s census profile -to_blocks() +to_blockmodel() contracts a network into a reduced graph of positions @@ -3334,7 +3334,7 @@

    Glossary

    engine = "r"), list(label = "varyclust", code = "alge <- to_named(ison_algebra) # fake names to make comparison clearer\nplot(node_in_structural(alge, cluster = \"hier\", distance = \"euclidean\"))\n\n# changing the type of distance used\nplot(node_in_structural(alge, cluster = \"hier\", distance = \"manhattan\"))\n\n# changing the clustering algorithm\nplot(node_in_structural(alge, cluster = \"concor\", distance = \"euclidean\"))", opts = list(label = "\"varyclust\"", exercise = "TRUE", exercise.setup = "\"data\""), engine = "r"), list( - label = "structblock", code = "(bm <- to_blocks(alge, node_in_structural(alge)))", + label = "structblock", code = "(bm <- to_blockmodel(alge, node_in_structural(alge)))", opts = list(label = "\"structblock\"", exercise = "TRUE", exercise.setup = "\"varyclust\"", warning = "FALSE"), engine = "r")), code_check = NULL, error_check = NULL, @@ -3355,7 +3355,7 @@

    Glossary

    message = TRUE, render = NULL, ref.label = NULL, child = NULL, engine = "r", split = FALSE, include = TRUE, purl = TRUE, max.print = 1000, label = "structblock", exercise = TRUE, - exercise.setup = "varyclust", code = "(bm <- to_blocks(alge, node_in_structural(alge)))", + exercise.setup = "varyclust", code = "(bm <- to_blockmodel(alge, node_in_structural(alge)))", out.width.px = 624, out.height.px = 384, params.src = "structblock, exercise = TRUE, exercise.setup = \"varyclust\", warning=FALSE", fig.num = 0, exercise.df_print = "paged", exercise.checker = "NULL"), engine = "r", version = "4"), class = c("r", "tutorial_exercise" @@ -3395,13 +3395,13 @@

    Glossary

    "friends <- to_uniplex(ison_algebra, \"friends\")", "social <- to_uniplex(ison_algebra, \"social\")", "tasks <- to_uniplex(ison_algebra, \"tasks\")", "alge <- to_named(ison_algebra)" ), chunk_opts = list(label = "setup", include = FALSE, purl = FALSE, - eval = TRUE)), setup = "\nalge <- to_named(ison_algebra) # fake names to make comparison clearer\nplot(node_in_structural(alge, cluster = \"hier\", distance = \"euclidean\"))\n\n# changing the type of distance used\nplot(node_in_structural(alge, cluster = \"hier\", distance = \"manhattan\"))\n\n# changing the clustering algorithm\nplot(node_in_structural(alge, cluster = \"concor\", distance = \"euclidean\"))\n(bm <- to_blocks(alge, node_in_structural(alge)))", + eval = TRUE)), setup = "\nalge <- to_named(ison_algebra) # fake names to make comparison clearer\nplot(node_in_structural(alge, cluster = \"hier\", distance = \"euclidean\"))\n\n# changing the type of distance used\nplot(node_in_structural(alge, cluster = \"hier\", distance = \"manhattan\"))\n\n# changing the clustering algorithm\nplot(node_in_structural(alge, cluster = \"concor\", distance = \"euclidean\"))\n(bm <- to_blockmodel(alge, node_in_structural(alge)))", chunks = list(list(label = "data", code = "", opts = list( label = "\"data\"", exercise = "TRUE", purl = "FALSE"), engine = "r"), list(label = "varyclust", code = "alge <- to_named(ison_algebra) # fake names to make comparison clearer\nplot(node_in_structural(alge, cluster = \"hier\", distance = \"euclidean\"))\n\n# changing the type of distance used\nplot(node_in_structural(alge, cluster = \"hier\", distance = \"manhattan\"))\n\n# changing the clustering algorithm\nplot(node_in_structural(alge, cluster = \"concor\", distance = \"euclidean\"))", opts = list(label = "\"varyclust\"", exercise = "TRUE", exercise.setup = "\"data\""), engine = "r"), list( - label = "structblock", code = "(bm <- to_blocks(alge, node_in_structural(alge)))", + label = "structblock", code = "(bm <- to_blockmodel(alge, node_in_structural(alge)))", opts = list(label = "\"structblock\"", exercise = "TRUE", exercise.setup = "\"varyclust\"", warning = "FALSE"), engine = "r"), list(label = "reducedgraph", code = "bm <- bm %>% as_tidygraph %>%\n mutate(name = c(\"Freaks\", \"Squares\", \"Nerds\", \"Geek\"))\ngraphr(bm)", From 69b2e665a9bdf4b304f550d2f75faae1a6c8eb71 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 14 Aug 2026 12:03:31 +0200 Subject: [PATCH 35/68] Improved consistency by consolidating every per-step discount as `decay` --- NEWS.md | 18 +++++------------- man-roxygen/param_decay.R | 7 +++++++ 2 files changed, 12 insertions(+), 13 deletions(-) create mode 100644 man-roxygen/param_decay.R diff --git a/NEWS.md b/NEWS.md index a87d975..eda9a3a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -11,24 +11,17 @@ ## Measures -- Improved `make_*_measure()` to record what was computed so results can be read without the script/manual +- Improved `make_*_measure()` to record algorithm details so results can be read without the script/manual - `measure`, the measure actually calculated, e.g. `node_by_degree()` reports "strength centrality" on a weighted network - `normalization`, one of `"normalized"`, `"scaled"`, `"proportional"` or `"none"` - `range`, the theoretical range of the returned values - `variant`, which variant was computed where a measure offers a choice, e.g. `net_by_reciprocity()` reports "ratio" when asked for the ratio - - Measures that set none of these behave exactly as before - - Printing them is a companion change in `{manynet}` + - Printing is a companion change in `{manynet}`, which defaults to previous behavior - Improved specificity of arguments, separating normalising from scaling - - Renamed `scale` argument to `scaled`, the old spelling still works but warns -- Added family-wide contract test sweeping every node-level centrality: - - that scores stay inside declared ranges - - that declared normalisations match values - - that arguments have an effect, - reporting any gaps as audit messages rather than failures -- Corrected claim in documentation that all measures return normalized values by default +- Improved consistency by consolidating every per-step discount as `decay` + - Always proportional [0,1] where higher values discount less - Added `net_by_cyclicality()` for detecting generalised exchange - Added `net_by_compactness()` for the average closeness of all pairs of nodes -- Added `decay` argument to `node_by_harmonic()`, and added `node_by_decay()` as a shortcut for decay centrality - Added `node_by_integration()` and `net_by_integration()` for Valente and Foreman's integration and radiality - Added `node_by_radiality()` as a shortcut for `node_by_integration(direction = "out")` @@ -45,8 +38,7 @@ - Normalised `node_by_vitality()` rescales finite scores onto `[0,1]` and places cut nodes at 0 - Improved `node_by_closeness()` to validate `direction` via `match.arg()` - Removed `direction` from `net_by_betweenness()` which never used it -- Moved `node_by_posneg()` (PN centrality) to eigenvector doc group as a - matrix-inversion walk-based measure — Katz for signed networks +- Moved `node_by_posneg()` to the eigenvector doc group as a Katz matrix-inversion walk-based measure for signed networks ## Memberships diff --git a/man-roxygen/param_decay.R b/man-roxygen/param_decay.R new file mode 100644 index 0000000..19052e2 --- /dev/null +++ b/man-roxygen/param_decay.R @@ -0,0 +1,7 @@ +#' @param decay A proportion between 0 and 1 giving how much of a contribution +#' survives each additional step of distance or walk length. +#' Lower values discount more steeply, so that only nearby others count; +#' higher values discount less, so that longer walks continue to contribute. +#' The measures that take a `decay` differ in what they discount and in what +#' value leaves the measure in its most familiar form, +#' so each documents its own default. From a17a7fe1dd6f3fd1c4776336b5b5e8236bf550a1 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Sun, 16 Aug 2026 11:45:30 +0200 Subject: [PATCH 36/68] pushrelease inherits release notes from description --- .github/workflows/pushrelease.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/pushrelease.yml b/.github/workflows/pushrelease.yml index 2af20a9..b61b3d7 100644 --- a/.github/workflows/pushrelease.yml +++ b/.github/workflows/pushrelease.yml @@ -107,12 +107,26 @@ jobs: echo "Renamed files" ls netrics_* + - name: Extract release notes from NEWS.md + shell: bash + run: | + # Take the NEWS.md section for this version, else the topmost section. + awk -v ver="${{ env.PACKAGE_NAME }} ${{ env.PACKAGE_VERSION }}" ' + /^# / { if (found) exit; if (substr($0, 3) == ver) { found = 1; next } } + found { print } + ' NEWS.md > RELEASE_NOTES.md + if [ ! -s RELEASE_NOTES.md ]; then + awk 'NR > 1 && /^# / { exit } NR > 1 { print }' NEWS.md > RELEASE_NOTES.md + fi + cat RELEASE_NOTES.md + - name: Create Release and Upload Assets id: create_release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.newtag.outputs.tag }} name: Release ${{ steps.newtag.outputs.tag }} + body_path: RELEASE_NOTES.md draft: false prerelease: false fail_on_unmatched_files: true From a82f38c3837b81a6703e26c61782c23dbd5392e1 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 06:51:26 +0200 Subject: [PATCH 37/68] Added `net_by_bipartivity()` for how close a network is to being bipartite --- NAMESPACE | 1 + NEWS.md | 1 + R/measure_features.R | 41 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/NAMESPACE b/NAMESPACE index ba9190d..30282af 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -17,6 +17,7 @@ export(net_by_adhesion) export(net_by_assortativity) export(net_by_balance) export(net_by_betweenness) +export(net_by_bipartivity) export(net_by_closeness) export(net_by_cohesion) export(net_by_compactness) diff --git a/NEWS.md b/NEWS.md index eda9a3a..9c209fd 100644 --- a/NEWS.md +++ b/NEWS.md @@ -39,6 +39,7 @@ - Improved `node_by_closeness()` to validate `direction` via `match.arg()` - Removed `direction` from `net_by_betweenness()` which never used it - Moved `node_by_posneg()` to the eigenvector doc group as a Katz matrix-inversion walk-based measure for signed networks +- Added `net_by_bipartivity()` for how close a network is to being bipartite ## Memberships diff --git a/R/measure_features.R b/R/measure_features.R index 1957588..0f813b4 100644 --- a/R/measure_features.R +++ b/R/measure_features.R @@ -197,7 +197,46 @@ net_by_scalefree <- function(.data){ make_network_measure(out$alpha, .data, call = deparse(sys.call())) } -#' @rdname measure_features +#' @rdname measure_features +#' @section Bipartivity: +#' A network is bipartite when its nodes divide into two sets with ties only +#' running between them and never within, which is exactly the condition that +#' it contains no closed walk of odd length. +#' Bipartivity therefore measures how close a network comes to that condition, +#' as the share of its closed walks that are of even length: +#' \deqn{b(G) = \frac{\sum_i C_{even}(i)}{\sum_i C_{all}(i)}} +#' A genuinely two-mode network scores exactly 1, +#' and the more odd-length structure a network carries — triangles above all — +#' the further it falls below 1. +#' Note that this asks whether a network _could_ be split in two, +#' not whether it has been: it is defined on a one-mode network, +#' whereas [manynet::is_twomode()] reports whether nodes are already +#' partitioned into two modes. +#' The node-level counterpart is [node_by_subgraph()] with +#' `method = "odd"` or `"even"`. +#' @references +#' ## On bipartivity +#' Estrada, Ernesto, and Juan A. Rodríguez-Velázquez. 2005. +#' "Spectral measures of bipartivity in complex networks". +#' _Physical Review E_ 72(4): 046105. +#' \doi{10.1103/PhysRevE.72.046105} +#' @examples +#' # A two-mode network is bipartite by construction +#' net_by_bipartivity(ison_southern_women) +#' net_by_bipartivity(ison_adolescents) +#' @export +net_by_bipartivity <- function(.data) { + .data <- manynet::expect_nodes(.data) + # Even-length closed walks as a share of all of them. Both counts are + # strictly positive, since the length-zero walk at each node is even. + out <- sum(.closed_walks(.data, method = "even")) / + sum(.closed_walks(.data, method = "all")) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "bipartivity", range = c(0, 1), + normalization = "normalized") +} + +#' @rdname measure_features #' @source `{signnet}` by David Schoch #' @references #' ## On balance theory From f3b1886d24dd8112f1ede17049e6b6f5c864cc31 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 07:07:28 +0200 Subject: [PATCH 38/68] Tightened some news sections --- NEWS.md | 43 ++++++++++-------------- man/member_equivalence.Rd | 14 ++++++-- man/method_regularity.Rd | 18 +++++++--- tests/testthat/test-member_equivalence.R | 9 ++++- 4 files changed, 49 insertions(+), 35 deletions(-) diff --git a/NEWS.md b/NEWS.md index 9c209fd..dfbfe82 100644 --- a/NEWS.md +++ b/NEWS.md @@ -44,40 +44,31 @@ ## Memberships - Added `node_in_labels()` for label propagation community detection -- Fixed `node_in_regular()` to compute regular equivalence using recursive - similarity (`regularity = "rolesim"` (default) or `"rege"`) between nodes - rather than a triad census +- Added `node_in_block()` for direct blockmodelling, searching partitions for the one that minimises `net_by_inconsistency()` +- Fixed `node_in_regular()` to compute regular equivalence using recursive similarity between nodes rather than a triad census + - Choose between `regularity = "rolesim"` (default) and `"rege"` - Note existing scripts calling `node_in_regular()` will now return more correct results - - Moved former behaviour of `node_in_regular()` to `node_in_motif()`, - documented as capturing similarity of local embedding not role equivalence -- Added `node_in_block()` for direct blockmodelling, searching partitions - for the one that minimises `net_by_inconsistency()` + - Moved former behaviour to `node_in_motif()`, documented as capturing similarity of local embedding rather than role equivalence ## Motifs -- Added `node_x_clique()`, returning which maximal cliques each node belongs to, - and branching on two-mode networks to find bicliques (closes #8, thanks @noortjemay) - - Note that `node_x_clique()` considers only positive ties, - since a clique is a cohesive subgroup -- Added `node_x_ties()`, describing the distribution of each node's tie values, -or its spread across layers in a multiplex network -- Added `node_x_alters()` and `node_x_similarity()`, describing the composition - of each node's alters and their similarity to it, each branching on whether - the attribute given is categorical or continuous - - For two-mode networks, `node_x_similarity()` compares each node with those - at distance two, that is, those it shares a node of the other mode with, - following the tertius effect of `{migraph}` and `{goldfish}` - (Haunss and Hollway 2023) -- Added `net_x_homophily()`, returning the table behind the EI index together - with an expected-EI baseline and Yule's Q - - Note that on weighted networks this counts ties where - `net_by_heterophily()` sums weights, so the two agree only when unweighted +- Added `node_x_clique()`, returning which maximal cliques each node belongs to + - It branches on two-mode networks to find bicliques (closes #8, thanks @noortjemay) + - Note that it considers only positive ties, since a clique is a cohesive subgroup +- Added `node_x_ties()`, describing the distribution of each node's tie values + - In a multiplex network it describes their spread across layers +- Added `node_x_alters()` and `node_x_similarity()`, describing the composition of each node's alters and their similarity to it + - Each branches on whether the attribute given is categorical or continuous + - For two-mode networks, `node_x_similarity()` compares each node with those at distance two + - These are the nodes it shares a node of the other mode with, following the tertius effect of `{migraph}` and `{goldfish}` (Haunss and Hollway 2023) +- Added `net_x_homophily()`, returning the table behind the EI index together with an expected-EI baseline and Yule's Q + - Note that on weighted networks this counts ties where `net_by_heterophily()` sums weights, so the two agree only when unweighted ## Methods - Added `regularity_rolesim()` and `regularity_rege()`, recursive role similarity methods - - Note `regularity_rege()` degenerate on unweighted connected ones, where it warns - + - Note `regularity_rege()` is degenerate on unweighted connected networks, where it warns + ## Tutorials - Updated position tutorial to use `node_in_regular()` for regular equivalence rather than the triad census diff --git a/man/member_equivalence.Rd b/man/member_equivalence.Rd index 0d27fec..fb23b94 100644 --- a/man/member_equivalence.Rd +++ b/man/member_equivalence.Rd @@ -37,7 +37,8 @@ node_in_regular( distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), Kmax = 8L, regularity = c("rolesim", "rege"), - beta = 0.15 + decay = 0.15, + beta = NULL ) node_in_motif( @@ -96,8 +97,15 @@ By default \code{"rolesim"}; \code{"rege"} is also available. Fewer, identifiable letters, e.g. \code{"ro"} for RoleSim, is sufficient. See \code{\link[=regularity_rolesim]{regularity_rolesim()}} and \code{\link[=regularity_rege]{regularity_rege()}} for how they differ.} -\item{beta}{A decay parameter between 0 and 1 passed to \code{\link[=regularity_rolesim]{regularity_rolesim()}}, -controlling how much weight is given to the recursive component.} +\item{decay}{A proportion between 0 and 1 giving how much of a contribution +survives each additional step of distance or walk length. +Lower values discount more steeply, so that only nearby others count; +higher values discount less, so that longer walks continue to contribute. +The measures that take a \code{decay} differ in what they discount and in what +value leaves the measure in its most familiar form, +so each documents its own default.} + +\item{beta}{Deprecated; use \code{decay} instead.} \item{blocks}{A character vector of permitted ideal block types, or a list-matrix giving the permitted types per block position. diff --git a/man/method_regularity.Rd b/man/method_regularity.Rd index 7425508..739788c 100644 --- a/man/method_regularity.Rd +++ b/man/method_regularity.Rd @@ -6,7 +6,7 @@ \alias{regularity_rege} \title{Methods for calculating regularity} \usage{ -regularity_rolesim(.data, beta = 0.15) +regularity_rolesim(.data, decay = 0.15, beta = NULL) regularity_rege(.data, iterations = 3) } @@ -15,8 +15,15 @@ regularity_rege(.data, iterations = 3) 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{beta}{A decay parameter between 0 and 1 controlling how much weight -is given to the recursive component. By default 0.15.} +\item{decay}{A proportion between 0 and 1 giving how much of a contribution +survives each additional step of distance or walk length. +Lower values discount more steeply, so that only nearby others count; +higher values discount less, so that longer walks continue to contribute. +The measures that take a \code{decay} differ in what they discount and in what +value leaves the measure in its most familiar form, +so each documents its own default.} + +\item{beta}{Deprecated; use \code{decay} instead.} \item{iterations}{Integer number of iterations. By default 3 for \code{regularity_rege()}; \code{regularity_rolesim()} iterates to convergence.} @@ -41,8 +48,9 @@ They differ in how they pair up two nodes' alters. RoleSim pairs up two nodes' alters by finding the \emph{maximal matching} between them, that is, the one-to-one pairing that maximises total similarity, and then averages over it: -\deqn{s(u,v) = (1-\beta) \frac{\sum_{(x,y) \in M} s(x,y)}{|N(u)| + |N(v)| - |M|} + \beta} -where \eqn{M} is that matching. +\deqn{s(u,v) = (1-\delta) \frac{\sum_{(x,y) \in M} s(x,y)}{|N(u)| + |N(v)| - |M|} + \delta} +where \eqn{M} is that matching and \eqn{\delta} is \code{decay}, +which RoleSim calls \eqn{\beta}; by default 0.15. Because each alter can be used only once, two nodes are similar only if their neighbourhoods can be lined up as wholes. diff --git a/tests/testthat/test-member_equivalence.R b/tests/testthat/test-member_equivalence.R index 4b63c64..457d9a3 100644 --- a/tests/testthat/test-member_equivalence.R +++ b/tests/testthat/test-member_equivalence.R @@ -48,7 +48,14 @@ test_that("regularity_rolesim satisfies automorphic confirmation", { expect_equal(r, t(r)) expect_true(all(diag(r) == 1)) expect_true(all(r >= 0 & r <= 1)) - expect_error(regularity_rolesim(ison_adolescents, beta = 2)) + expect_error(regularity_rolesim(ison_adolescents, decay = 2)) +}) + +test_that("renamed `beta` argument still works, with a warning", { + expect_warning(regularity_rolesim(ison_adolescents, beta = 0.2), "renamed") + expect_equal(suppressWarnings(regularity_rolesim(ison_adolescents, beta = 0.2)), + regularity_rolesim(ison_adolescents, decay = 0.2)) + expect_warning(node_in_regular(ison_adolescents, beta = 0.2), "renamed") }) test_that("regularity_rege discriminates on valued networks", { From c29553508f1f5ba98fad382589d576db643c5ad7 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 07:16:49 +0200 Subject: [PATCH 39/68] Improved argument consistency and measure reporting --- NEWS.md | 61 ++- R/class_metrics.R | 47 ++- R/measure_centrality_between.R | 65 ++- R/measure_centrality_closeness.R | 63 ++- R/measure_centrality_degree.R | 44 +- R/measure_centrality_eigen.R | 154 +++++-- R/measure_change.R | 4 +- R/measure_closure.R | 84 ++-- R/measure_cohesion.R | 54 ++- R/measure_diffusion.R | 64 ++- R/measure_features.R | 82 +++- R/measure_heterogeneity.R | 87 +++- R/measure_hierarchy.R | 41 +- R/measure_holes.R | 33 +- R/member_core.R | 6 +- R/member_equivalence.R | 9 +- R/method_regularity.R | 18 +- R/motif_brokerage.R | 6 +- man/measure_assort_net.Rd | 18 + man/measure_assort_node.Rd | 7 + man/measure_breadth.Rd | 7 + man/measure_broker_node.Rd | 16 + man/measure_broker_tie.Rd | 10 + man/measure_brokerage.Rd | 7 + man/measure_central_between.Rd | 45 +- man/measure_central_close.Rd | 53 ++- man/measure_central_degree.Rd | 24 ++ man/measure_central_eigen.Rd | 91 ++++- man/measure_centralisation_close.Rd | 12 +- man/measure_centralisation_degree.Rd | 11 + man/measure_centralities_between.Rd | 30 ++ man/measure_centralities_close.Rd | 7 + man/measure_centralities_degree.Rd | 7 + man/measure_centralities_eigen.Rd | 7 + man/measure_closure.Rd | 12 +- man/measure_closure_node.Rd | 30 +- man/measure_cohesion.Rd | 7 + man/measure_core.Rd | 7 + man/measure_diffusion_infection.Rd | 7 + man/measure_diffusion_net.Rd | 12 + man/measure_diffusion_node.Rd | 7 + man/measure_diverse_net.Rd | 7 + man/measure_diverse_node.Rd | 7 + man/measure_features.Rd | 51 ++- man/measure_fit.Rd | 17 +- man/measure_fragmentation.Rd | 7 + man/measure_hierarchy.Rd | 20 + man/measure_periods.Rd | 7 + tests/testthat/helper-contract.R | 385 ++++++++++++++++++ .../test-measure_centrality_contract.R | 228 ++++------- .../testthat/test-measure_closure_contract.R | 50 +++ .../testthat/test-measure_cohesion_contract.R | 22 + .../test-measure_diffusion_contract.R | 35 ++ .../testthat/test-measure_features_contract.R | 46 +++ .../test-measure_heterogeneity_contract.R | 52 +++ tests/testthat/test-measure_hierarchy.R | 16 +- tests/testthat/test-measure_holes_contract.R | 18 + tests/testthat/test-measure_misc_contract.R | 38 ++ tests/testthat/test-measure_nodes.R | 1 - .../testthat/test-measure_registry_contract.R | 23 ++ 60 files changed, 1993 insertions(+), 393 deletions(-) create mode 100644 tests/testthat/helper-contract.R create mode 100644 tests/testthat/test-measure_closure_contract.R create mode 100644 tests/testthat/test-measure_cohesion_contract.R create mode 100644 tests/testthat/test-measure_diffusion_contract.R create mode 100644 tests/testthat/test-measure_features_contract.R create mode 100644 tests/testthat/test-measure_heterogeneity_contract.R create mode 100644 tests/testthat/test-measure_holes_contract.R create mode 100644 tests/testthat/test-measure_misc_contract.R create mode 100644 tests/testthat/test-measure_registry_contract.R diff --git a/NEWS.md b/NEWS.md index dfbfe82..ff3ba11 100644 --- a/NEWS.md +++ b/NEWS.md @@ -17,29 +17,72 @@ - `range`, the theoretical range of the returned values - `variant`, which variant was computed where a measure offers a choice, e.g. `net_by_reciprocity()` reports "ratio" when asked for the ratio - Printing is a companion change in `{manynet}`, which defaults to previous behavior + - Added `measure`, `range`, `normalization`, and `variant` reporting to every measure where applicable +- Updated documentation such that the measures that certain arguments produce are discoverable by name + - `node_by_betweenness(cutoff = k)` is distance-bounded or range-limited betweenness + - `node_by_reach(cutoff = k)` is geodesic k-path centrality - Improved specificity of arguments, separating normalising from scaling + - Renamed `scale` argument to `scaled`; old spelling still works but warns + - Corrected documentation claiming that all measures return normalized values by default - Improved consistency by consolidating every per-step discount as `decay` - Always proportional [0,1] where higher values discount less + - Was `alpha` in `node_by_alpha()`, `beta` in `regularity_rolesim()` + - `alpha` now only refers to Opsahl et al.'s trade-off between degree and strength in `node_by_degree()` + - Added `decay` argument to `node_by_harmonic()`, and `node_by_decay()` as a shortcut for decay centrality + - Added `decay` to `node_by_pagerank()`, exposing the damping factor previously fixed at 0.85 + - Added `decay` to `node_by_subgraph()`, weighting closed walks by length, which Estrada calls `t` + - Old spellings still work but warn, as `scale` does - Added `net_by_cyclicality()` for detecting generalised exchange - Added `net_by_compactness()` for the average closeness of all pairs of nodes -- Added `node_by_integration()` and `net_by_integration()` for Valente and - Foreman's integration and radiality +- Added `node_by_integration()` and `net_by_integration()` for Valente and Foreman's integration and radiality - Added `node_by_radiality()` as a shortcut for `node_by_integration(direction = "out")` -- Added `net_by_inconsistency()`, which scores how far a partition's blocks depart - from ideal types (`nul`, `com`, `reg`, `rdo`, `cdo`, `dnc`), generalising - `net_by_factions()` beyond structural equivalence - Fixed `node_by_degree()` to default to `alpha = 0` to match documentation -- Fixed `mode_by_betweenness()` to accepts only `"all"` and `"in"`, as implemented +- Fixed `mode_by_betweenness()` to accept only `"all"` and `"in"`, as implemented - Fixed `node_by_reach()` counting the node itself so normalised scores could exceed 1 -- Fixed `node_by_eigenvector()` discarding tie weights it had computed, silently returning unweighted scores for weighted networks -- Fixed `tie_by_betweenness()`, `node_by_randomwalk()` and `node_by_betweenness()` (when given a `cutoff`) accepting `normalized` and then ignoring it +- Fixed `node_by_eigenvector()` discarding tie weights it had computed +- Fixed `tie_by_betweenness()` and `node_by_randomwalk()` accepting `normalized` and then ignoring it +- Fixed `node_by_betweenness()` accepting `normalized` and then ignoring it when given a `cutoff` - Fixed how `node_by_vitality()` treats cut nodes - Unnormalised returns `-Inf` for cut nodes as the Wiener index definition requires - - Normalised `node_by_vitality()` rescales finite scores onto `[0,1]` and places cut nodes at 0 + - Normalised rescales finite scores onto `[0,1]` and places cut nodes at 0 +- Fixed `net_by_efficiency()` to implement Krackhardt's definition as share of possible excess ties a network leaves unused + - Previously unbounded `(n-1)/sum(indegree)`, so now `net_x_hierarchy()` compares efficiency against three quantities already on `[0,1]` +- Fixed `net_by_immunity()` returning a negative herd immunity threshold when \eqn{R < 1} +- Fixed `net_by_density()`, `net_by_equivalency()` and `node_by_reciprocity()` summing tie weights where they should have counted ties + - Weighted networks could report a proportion above 1 + - All three now dichotomise their input, and say so when given weights - Improved `node_by_closeness()` to validate `direction` via `match.arg()` - Removed `direction` from `net_by_betweenness()` which never used it - Moved `node_by_posneg()` to the eigenvector doc group as a Katz matrix-inversion walk-based measure for signed networks +- Added `method` to `node_by_subgraph()` + - `method` chooses which closed walks to count: `"all"` (the default), `"odd"` or `"even"` + - These sum as `"odd" + "even" == "all"` +- Improved `node_by_subgraph()` to honour tie weights +- Documented measure aliases + - `node_by_closeness()` as the Sabidussi index + - `node_by_degree()` on a weighted network as strength or weighted degree centrality + - `node_by_alpha()` as Katz status + - `node_by_hub()` and `node_by_authority()` as the two halves of Kleinberg's HITS + - `node_by_transitivity()` as the local clustering coefficient + - `tie_by_betweenness()` as edge betweenness + - `node_by_subgraph()` as a node's contribution to the Estrada index + - `node_by_induced()` and `node_by_vitality()` are the betweenness and closeness instances of Latora and Marchiori's delta centrality, and cross-referenced them to each other + - `node_by_information()` is the closeness member of the current-flow family, whose betweenness member netrics does not yet offer + - Stopped `node_by_induced()` also calling itself "vitality centrality", which collided with `node_by_vitality()` +- Updated references in centrality documentation + - Corrected `node_by_eigenvector()` to cite Bonacich (1972) as the origin of the measure, rather than only Bonacich (1991) + - Added Freeman (1978) to `node_by_degree()` and the centralisation functions, the source of the centralisation index they apply + - Added references to `tie_by_betweenness()`, which had none + - Added Sabidussi (1966) to closeness, Boldi and Vigna (2014) to harmonic, Borgatti and Everett (2006) to reach, Brandes (2008) and Ercsey-Ravasz et al. (2012) to betweenness, Watts and Strogatz (1998) and Holland and Leinhardt (1971) to node transitivity, and Page et al. (1999) to pagerank - Added `net_by_bipartivity()` for how close a network is to being bipartite +- Added `net_by_inconsistency()` for how far a partition's blocks depart from ideal types + - Ideal types are `nul`, `com`, `reg`, `rdo`, `cdo` and `dnc` + - Generalises `net_by_factions()` beyond structural equivalence +- Fixed `node_by_equivalency()` erroring on any network, despite being documented for the two-mode case +- Fixed `node_by_diversity()` reporting an undefined object in its message about substituting an inapplicable index +- Corrected `net_by_transmissibility()` to no longer declare itself a proportion + - At-risk denominator recorded at the end of each period rather than the start, so can exceed 1 +- Added family-wide contract test sweeping every measure for declared ranges, normalisation, and argument effects ## Memberships diff --git a/R/class_metrics.R b/R/class_metrics.R index ab5de1d..f8f46f6 100644 --- a/R/class_metrics.R +++ b/R/class_metrics.R @@ -71,16 +71,45 @@ resolve_scaled <- function(scaled, scale = NULL) { scaled } +# Several measures discount a contribution once per step of distance or walk +# length. The literature names that discount differently in each case — +# Bonacich and Lloyd's alpha, RoleSim's beta, PageRank's damping factor, +# the t of subgraph centrality — but it is one parameter, so netrics calls it +# `decay` everywhere: higher values discount less, so longer walks count for +# more. These two helpers keep that vocabulary in step. + +# Accepts a superseded spelling and warns, as `resolve_scaled()` does. +resolve_decay <- function(decay, old = NULL, old_name) { + if(!is.null(old)) { + warning("The `", old_name, "` argument has been renamed `decay`, ", + "the name this package uses for a per-step discount. ", + "Please use `decay` instead.", call. = FALSE) + decay <- old + } + decay +} + +# The single bound for every such discount, so that the message and the +# accepted range cannot drift apart between measures. +check_decay <- function(decay, arg = "decay") { + if(!is.numeric(decay) || length(decay) != 1L || !is.finite(decay) || + decay < 0 || decay > 1) + # `arg` is interpolated by `snet_abort()`, so it is passed as a value + # rather than pasted into the string. + manynet::snet_abort("`{arg}` must be a proportion between 0 and 1.") + decay +} + make_node_measure <- function(out, .data, measure = NULL, range = NULL, - normalization = NULL) { + normalization = NULL, variant = NULL) { if(manynet::is_labelled(.data)) names(out) <- manynet::node_names(.data) class(out) <- c("node_measure", class(out)) attr(out, "mode") <- manynet::node_is_mode(.data) - set_measure_attributes(out, measure, range, normalization) + set_measure_attributes(out, measure, range, normalization, variant) } make_tie_measure <- function(out, .data, measure = NULL, range = NULL, - normalization = NULL) { + normalization = NULL, variant = NULL) { class(out) <- c("tie_measure", class(out)) if(manynet::is_labelled(.data)){ tie_names <- attr(igraph::E(.data), "vnames") @@ -93,23 +122,25 @@ make_tie_measure <- function(out, .data, measure = NULL, range = NULL, names(out) <- paste0(ties$from, "->", ties$to) else names(out) <- paste0(ties$from, "-", ties$to) } - set_measure_attributes(out, measure, range, normalization) + set_measure_attributes(out, measure, range, normalization, variant) } make_network_measure <- function(out, .data, call, measure = NULL, - range = NULL, normalization = NULL) { + range = NULL, normalization = NULL, + variant = NULL) { class(out) <- c("network_measure", class(out)) attr(out, "mode") <- manynet::net_dims(.data) attr(out, "call") <- call - set_measure_attributes(out, measure, range, normalization) + set_measure_attributes(out, measure, range, normalization, variant) } make_mode_measure <- function(out, .data, call, measure = NULL, - range = NULL, normalization = NULL) { + range = NULL, normalization = NULL, + variant = NULL) { class(out) <- c("mode_measure", "network_measure", class(out)) attr(out, "mode") <- manynet::net_dims(.data) attr(out, "call") <- call - set_measure_attributes(out, measure, range, normalization) + set_measure_attributes(out, measure, range, normalization, variant) } make_node_member <- function(out, .data) { diff --git a/R/measure_centrality_between.R b/R/measure_centrality_between.R index 1d3d2cf..1924f3b 100644 --- a/R/measure_centrality_between.R +++ b/R/measure_centrality_between.R @@ -45,12 +45,29 @@ NULL #' Betweenness centrality is based on the number of shortest paths between #' other nodes that a node lies upon: #' \deqn{C_B(i) = \sum_{j,k:j \neq k, j \neq i, k \neq i} \frac{g_{jik}}{g_{jk}}} +#' +#' Setting `cutoff` counts only those shortest paths no longer than \eqn{k}, +#' which elsewhere goes by _distance-bounded betweenness_ (Brandes, 2008) or +#' _range-limited betweenness_ (Ercsey-Ravasz et al., 2012). +#' Normalization still applies, so a bounded score remains comparable across +#' networks. #' @references #' ## On betweenness centrality -#' Freeman, Linton. 1977. -#' "A set of measures of centrality based on betweenness". -#' _Sociometry_, 40(1): 35–41. +#' Freeman, Linton. 1977. +#' "A set of measures of centrality based on betweenness". +#' _Sociometry_, 40(1): 35–41. #' \doi{10.2307/3033543} +#' +#' ## On bounding path length +#' Brandes, Ulrik. 2008. +#' "On variants of shortest-path betweenness centrality and their generic computation". +#' _Social Networks_ 30(2): 136-145. +#' \doi{10.1016/j.socnet.2007.11.001} +#' +#' Ercsey-Ravasz, Maria, Ryan N. Lichtenwalter, Nitesh V. Chawla, and Zoltan Toroczkai. 2012. +#' "Range-limited centrality measures in complex networks". +#' _Physical Review E_ 85(6): 066103. +#' \doi{10.1103/PhysRevE.85.066103} #' @examples #' node_by_betweenness(ison_southern_women) #' @export @@ -86,16 +103,26 @@ node_by_betweenness <- function(.data, normalized = TRUE, } #' @rdname measure_central_between -#' @section Induced centrality: -#' Induced centrality or vitality centrality concerns the change in -#' total betweenness centrality between networks with and without a given node: +#' @section Induced centrality: +#' Induced centrality concerns the change in total betweenness centrality +#' between networks with and without a given node: #' \deqn{C_I(i) = C_B(G) - C_B(G\ i)} +#' This "remove the node and re-measure" logic is the general +#' _delta centrality_ framework of Latora and Marchiori (2007); +#' `node_by_induced()` is its betweenness instance, and +#' [node_by_vitality()] its closeness instance. #' @references #' ## On induced centrality #' Everett, Martin and Steve Borgatti. 2010. #' "Induced, endogenous and exogenous centrality" #' _Social Networks_, 32: 339-344. #' \doi{10.1016/j.socnet.2010.06.004} +#' +#' ## On delta centrality +#' Latora, Vito, and Massimo Marchiori. 2007. +#' "A measure of centrality based on network efficiency". +#' _New Journal of Physics_ 9(6): 188. +#' \doi{10.1088/1367-2630/9/6/188} #' @examples #' node_by_induced(ison_adolescents) #' @export @@ -123,10 +150,11 @@ node_by_induced <- function(.data, normalized = TRUE, #' sum of flows \eqn{f(i,j,G)}. #' @references #' ## On flow centrality -#' Freeman, Lin, Stephen Borgatti, and Douglas White. 1991. -#' "Centrality in Valued Graphs: A Measure of Betweenness Based on Network Flow". +#' Freeman, Linton C., Stephen P. Borgatti, and Douglas R. White. 1991. +#' "Centrality in Valued Graphs: A Measure of Betweenness Based on Network Flow". #' _Social Networks_, 13(2), 141-154. -#' +#' \doi{10.1016/0378-8733(91)90017-N} +#' #' Koschutzki, D., K.A. Lehmann, L. Peeters, S. Richter, D. Tenfelde-Podehl, and O. Zlotowski. 2005. #' "Centrality Indices". #' In U. Brandes and T. Erlebach (eds.), _Network Analysis: Methodological Foundations_. @@ -171,7 +199,7 @@ node_by_stress <- function(.data, normalized = TRUE){ # so the result is a set of shares rather than a [0,1] normalisation. make_node_measure(out, .data, measure = "stress centrality", range = `if`(normalized, c(0, 1), c(0, Inf)), - normalization = `if`(normalized, "proportion", "none")) + normalization = `if`(normalized, "proportional", "none")) } # Tie betweenness centrality #### @@ -195,6 +223,23 @@ node_by_stress <- function(.data, normalized = TRUE){ NULL #' @rdname measure_centralities_between +#' @section Edge betweenness centrality: +#' The betweenness centrality of a tie, also known as _edge betweenness_, +#' counts the shortest paths between other nodes that run along it. +#' It is best known as the quantity iteratively recomputed by the +#' Girvan-Newman community detection algorithm, where the ties with the +#' highest betweenness are removed first; see [node_in_betweenness()]. +#' @references +#' ## On edge betweenness centrality +#' Girvan, Michelle, and Mark E.J. Newman. 2002. +#' "Community structure in social and biological networks". +#' _Proceedings of the National Academy of Sciences_ 99(12): 7821-7826. +#' \doi{10.1073/pnas.122653799} +#' +#' Brandes, Ulrik. 2001. +#' "A faster algorithm for betweenness centrality". +#' _Journal of Mathematical Sociology_ 25(2): 163-177. +#' \doi{10.1080/0022250X.2001.9990249} #' @importFrom igraph edge_betweenness #' @examples #' (tb <- tie_by_betweenness(ison_adolescents)) diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index 8b2758f..1c0845f 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -54,21 +54,27 @@ NULL #' @rdname measure_central_close #' @section Closeness centrality: -#' Closeness centrality, status centrality, or barycenter centrality is -#' defined as the reciprocal of the farness or distance, \eqn{d}, +#' Closeness centrality is also known as status centrality, +#' barycenter centrality, or the Sabidussi index. +#' It is defined as the reciprocal of the farness or distance, \eqn{d}, #' from a node to all other nodes in the network: #' \deqn{C_C(i) = \frac{1}{\sum_j d(i,j)}} #' When (more commonly) normalised, the numerator is instead \eqn{N-1}. #' @references #' ## On closeness centrality -#' Bavelas, Alex. 1950. -#' "Communication Patterns in Task‐Oriented Groups". +#' Sabidussi, Gert. 1966. +#' "The centrality index of a graph". +#' _Psychometrika_, 31(4): 581–603. +#' \doi{10.1007/BF02289527} +#' +#' Bavelas, Alex. 1950. +#' "Communication Patterns in Task‐Oriented Groups". #' _The Journal of the Acoustical Society of America_, 22(6): 725–730. #' \doi{10.1121/1.1906679} -#' -#' Harary, Frank. 1959. -#' "Status and Contrastatus". -#' _Sociometry_, 22(1): 23–43. +#' +#' Harary, Frank. 1959. +#' "Status and Contrastatus". +#' _Sociometry_, 22(1): 23–43. #' \doi{10.2307/2785610} #' @examples #' node_by_closeness(ison_southern_women) @@ -126,6 +132,11 @@ node_by_closeness <- function(.data, normalized = TRUE, #' Dekker, Anthony. 2005. #' "Conceptual distance in social network analysis". #' _Journal of Social Structure_ 6(3). +#' +#' Boldi, Paolo, and Sebastiano Vigna. 2014. +#' "Axioms for Centrality". +#' _Internet Mathematics_ 10(3-4): 222-262. +#' \doi{10.1080/15427951.2013.865686} #' @export node_by_harmonic <- function(.data, normalized = TRUE, cutoff = -1, decay = NULL, direction = c("out", "in")){ @@ -137,8 +148,7 @@ node_by_harmonic <- function(.data, normalized = TRUE, cutoff = -1, normalized = normalized, cutoff = cutoff) meas <- "harmonic centrality" } else { - if(decay < 0 | decay > 1) - manynet::snet_abort("`decay` must be a proportion between 0 and 1.") + check_decay(decay) # note that igraph's default mode ignores direction, which would treat a # directed network as though every tie ran both ways dists <- igraph::distances(manynet::as_igraph(.data), mode = direction) @@ -170,11 +180,20 @@ node_by_harmonic <- function(.data, normalized = TRUE, cutoff = -1, #' but the normalised version, \eqn{\frac{C_R}{N-1}}, is more common. #' Note that if \eqn{k = 1} (i.e. cutoff = 1), then this returns the node's degree. #' At higher cutoff reach centrality returns the size of the node's component. +#' Counting the others reachable by a geodesic of length at most \eqn{k} is +#' also known as _geodesic \eqn{k}-path centrality_ (Borgatti and Everett, 2006); +#' note that it is not the same as the \eqn{k}-path indices that count paths +#' rather than nodes. #' @references #' ## On reach centrality -#' Borgatti, Stephen P., Martin G. Everett, and J.C. Johnson. 2013. -#' _Analyzing social networks_. +#' Borgatti, Stephen P., Martin G. Everett, and J.C. Johnson. 2013. +#' _Analyzing social networks_. #' London: SAGE Publications Limited. +#' +#' Borgatti, Stephen P., and Martin G. Everett. 2006. +#' "A graph-theoretic perspective on centrality". +#' _Social Networks_ 28(4): 466-484. +#' \doi{10.1016/j.socnet.2005.11.005} #' @examples #' node_by_reach(ison_adolescents) #' @export @@ -195,12 +214,12 @@ node_by_reach <- function(.data, normalized = TRUE, cutoff = 2){ } #' @rdname measure_central_close -#' @param decay A proportion between 0 and 1 indicating how quickly -#' the contribution of more distant nodes decays. -#' By default 0.5, so that each additional step halves a node's contribution. -#' As `decay` approaches 0 this approaches degree centrality, -#' and as it approaches 1 this approaches the size of the node's component. +#' @template param_decay #' @section Decay centrality: +#' Here `decay` defaults to 0.5, so that each additional step halves a node's +#' contribution. As it approaches 0 this approaches degree centrality, +#' and as it approaches 1 the size of the node's component. +#' #' Where reach centrality counts how many others are within a fixed number of #' steps, decay centrality weights every reachable other by how far away they #' are, so that nearer nodes count for more: @@ -300,6 +319,10 @@ node_by_radiality <- function(.data, normalized = TRUE){ #' Nodes with higher information centrality have a large number of short paths #' to many others in the network, and are thus considered to have greater #' control of the flow of information. +#' +#' Information centrality is the closeness-like member of the current-flow +#' family; its betweenness-like counterpart is random walk (or current-flow) +#' betweenness centrality, which netrics does not yet offer. #' @references #' ## On information centrality #' Stephenson, Karen, and Marvin Zelen. 1989. @@ -323,7 +346,7 @@ node_by_information <- function(.data, normalized = TRUE){ # so the result is a set of shares rather than a [0,1] normalisation. make_node_measure(out, .data, measure = "information centrality", range = `if`(normalized, c(0, 1), c(0, Inf)), - normalization = `if`(normalized, "proportion", "none")) + normalization = `if`(normalized, "proportional", "none")) } #' @rdname measure_central_close @@ -406,6 +429,8 @@ node_by_distance <- function(.data, from, to, normalized = TRUE){ #' \deqn{C_V(i) = \sum_{j,k} d(j,k) - \sum_{j,k} d(j,k,G\ i)} #' where \eqn{d(j,k,G\ i)} is the distance between nodes \eqn{j} and \eqn{k} #' in the network with node \eqn{i} removed. +#' This is the closeness instance of the _delta centrality_ framework; +#' for its betweenness instance see [node_by_induced()]. #' @references #' ## On closeness vitality centrality #' Koschuetzki, Dirk, Katharina Lehmann, Leon Peeters, Stefan Richter, @@ -542,7 +567,7 @@ NULL #' @export tie_by_closeness <- function(.data, normalized = TRUE){ .data <- manynet::expect_ties(.data) - edge_adj <- manynet::to_ties(.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", diff --git a/R/measure_centrality_degree.R b/R/measure_centrality_degree.R index 91a247f..342ee17 100644 --- a/R/measure_centrality_degree.R +++ b/R/measure_centrality_degree.R @@ -47,9 +47,15 @@ #' the higher score. #' This argument is ignored except in the case of a weighted network. #' @importFrom igraph graph_from_incidence_matrix is_bipartite degree V -#' @references +#' @references +#' ## On degree centrality +#' Freeman, Linton C. 1978. +#' "Centrality in social networks: Conceptual clarification". +#' _Social Networks_ 1(3): 215-239. +#' \doi{10.1016/0378-8733(78)90021-7} +#' #' ## On multimodal centrality -#' Faust, Katherine. 1997. +#' Faust, Katherine. 1997. #' "Centrality in affiliation networks." #' _Social Networks_ 19(2): 157-191. #' \doi{10.1016/S0378-8733(96)00300-0} @@ -84,9 +90,15 @@ NULL #' The total degree of a network is the sum of all degrees, \eqn{\sum_v d(v)}. #' The degree sequence is the set of all nodes' degrees, #' ordered from largest to smallest. -#' Directed networks discriminate between +#' Directed networks discriminate between #' outdegree (degree of outgoing ties) and #' indegree (degree of incoming ties). +#' @section Strength centrality: +#' Given a weighted network, `node_by_degree()` sums tie weights rather than +#' counting ties, which is also known as _strength centrality_ or _weighted +#' degree centrality_. The `alpha` argument tunes between the two, following +#' Opsahl et al. (2010), and the measure reports itself as +#' "strength centrality" whenever `alpha` is not zero. #' @importFrom manynet as_igraph is_weighted tie_weights is_twomode is_complex #' @export node_by_degree <- function (.data, normalized = TRUE, alpha = 0, @@ -169,14 +181,27 @@ node_by_indegree <- function (.data, normalized = TRUE, alpha = 0){ node_by_multidegree <- function (.data, tie1, tie2){ .data <- manynet::expect_nodes(.data) stopifnot(manynet::is_multiplex(.data)) - out <- node_by_degree(manynet::to_uniplex(.data, tie1)) - - node_by_degree(manynet::to_uniplex(.data, tie2)) + out <- uniplex_degree(.data, tie1) - uniplex_degree(.data, tie2) # Bounded by construction rather than divided by a maximum: the difference # of two normalised degrees. make_node_measure(out, .data, measure = "multidegree centrality", range = c(-1, 1), normalization = "none") } +# Degree in one layer of a multiplex network, kept at the length of the +# whole nodeset. `to_uniplex()` drops nodes that hold none of the retained +# ties (e.g. a whole mode of a twomode layer), so the two layers' degrees +# would otherwise be of different lengths and get recycled. +uniplex_degree <- function(.data, tie) { + layer <- manynet::to_uniplex(.data, tie) + deg <- as.numeric(node_by_degree(layer)) + if (length(deg) == manynet::net_nodes(.data)) return(deg) + out <- stats::setNames(rep(0, manynet::net_nodes(.data)), + manynet::node_names(.data)) + out[manynet::node_names(layer)] <- deg + unname(out) +} + #' @rdname measure_central_degree #' @section Leverage centrality: #' Leverage centrality concerns the degree of a node compared with that of its @@ -225,7 +250,7 @@ NULL #' @export tie_by_degree <- function(.data, normalized = TRUE){ .data <- manynet::expect_ties(.data) - edge_adj <- manynet::to_ties(.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", @@ -271,6 +296,13 @@ tie_by_degree <- function(.data, normalized = TRUE){ #' @family degree #' @family centrality #' @references +#' ## On centralisation +#' Freeman, Linton C. 1978. +#' "Centrality in social networks: Conceptual clarification". +#' _Social Networks_ 1(3): 215-239. +#' \doi{10.1016/0378-8733(78)90021-7} +#' +#' ## On two-mode centralisation #' Borgatti, Stephen P., and Martin G. Everett. 1997. #' "Network analysis of 2-mode data." #' _Social Networks_ 19(3): 243-269. diff --git a/R/measure_centrality_eigen.R b/R/measure_centrality_eigen.R index 6f49dd3..b84c5ee 100644 --- a/R/measure_centrality_eigen.R +++ b/R/measure_centrality_eigen.R @@ -64,11 +64,16 @@ NULL #' @details #' We use `{igraph}` routines behind the scenes here for consistency and because they are often faster. #' For example, `igraph::eigencentrality()` is approximately 25% faster than `sna::evcent()`. -#' @references +#' @references #' ## On eigenvector centrality -#' Bonacich, Phillip. 1991. -#' “Simultaneous Group and Individual Centralities.” -#' _Social Networks_ 13(2):155–68. +#' Bonacich, Phillip. 1972. +#' “Factoring and Weighting Approaches to Status Scores and Clique Identification.” +#' _The Journal of Mathematical Sociology_ 2(1): 113–120. +#' \doi{10.1080/0022250X.1972.9989806} +#' +#' Bonacich, Phillip. 1991. +#' “Simultaneous Group and Individual Centralities.” +#' _Social Networks_ 13(2):155–68. #' \doi{10.1016/0378-8733(91)90018-O} #' @examples #' node_by_eigenvector(ison_southern_women) @@ -181,16 +186,23 @@ node_by_power <- function(.data, normalized = TRUE, scaled = FALSE, # onto [0,1]; `scaled = TRUE` instead returns shares summing to one. make_node_measure(out, .data, measure = "power centrality", range = `if`(scaled, c(0, 1), c(-Inf, Inf)), - normalization = `if`(scaled, "proportion", "none")) + normalization = `if`(scaled, "proportional", "none")) } -#' @rdname measure_central_eigen -#' @param alpha A constant that trades off the importance of external influence against the importance of connection. -#' When \eqn{\alpha = 0}, only the external influence matters. -#' As \eqn{\alpha} gets larger, only the connectivity matters and we reduce to eigenvector centrality. -#' By default \eqn{\alpha = 0.85}. +#' @rdname measure_central_eigen +#' @template param_decay +#' @param alpha Deprecated; use `decay` instead. #' @section Alpha centrality: -#' Alpha or Katz (or Katz-Bonacich) centrality operates better than +#' Alpha centrality is also known as Katz centrality, Katz-Bonacich +#' centrality, or Katz status. +#' The measure is named for the \eqn{\alpha} of Bonacich and Lloyd, which +#' trades off the importance of external influence against the importance of +#' connection: when \eqn{\alpha = 0} only the external influence matters, and +#' as \eqn{\alpha} grows only the connectivity matters and we reduce to +#' eigenvector centrality. +#' Since \eqn{\alpha} is a per-step discount, netrics takes it as `decay`, +#' the name it uses for that parameter throughout; by default 0.85. +#' It operates better than #' eigenvector centrality for directed networks because eigenvector centrality #' will return 0s for all nodes not in the main strongly-connected component. #' Each node's alpha centrality can be defined as: @@ -218,41 +230,63 @@ node_by_power <- function(.data, normalized = TRUE, scaled = FALSE, #' Bonacich, P. and Lloyd, P. 2001. #' “Eigenvector-like measures of centrality for asymmetric relations” #' _Social Networks_. 23(3):191-201. -#' @export -node_by_alpha <- function(.data, alpha = 0.85){ +#' @export +node_by_alpha <- function(.data, decay = 0.85, alpha = NULL){ .data <- manynet::expect_nodes(.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. make_node_measure(igraph::alpha_centrality(manynet::as_igraph(.data), - alpha = alpha), + alpha = decay), .data, measure = "alpha centrality", range = c(-Inf, Inf), normalization = "none") } -#' @rdname measure_central_eigen -#' @references +#' @rdname measure_central_eigen +#' @section Pagerank centrality: +#' Pagerank centrality, or the PageRank citation ranking, is the stationary +#' distribution of a random walk that at each step either follows an outgoing +#' tie or teleports to a node chosen at random. +#' Scores are therefore already shares that sum to one. +#' `decay` is the probability of following a tie rather than teleporting, +#' elsewhere called the damping factor; by default 0.85. +#' As it approaches 0 the walk teleports at every step and all nodes score +#' alike; as it approaches 1 the walk never teleports. +#' @references #' ## On pagerank centrality #' Brin, Sergey and Page, Larry. 1998. #' "The anatomy of a large-scale hypertextual web search engine". #' _Proceedings of the 7th World-Wide Web Conference_. Brisbane, Australia. -#' @export -node_by_pagerank <- function(.data){ +#' +#' Page, Lawrence, Sergey Brin, Rajeev Motwani, and Terry Winograd. 1999. +#' "The PageRank Citation Ranking: Bringing Order to the Web". +#' _Stanford InfoLab Technical Report_ 1999-66. +#' @export +node_by_pagerank <- function(.data, decay = 0.85){ .data <- manynet::expect_nodes(.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. - make_node_measure(igraph::page_rank(manynet::as_igraph(.data))$vector, + make_node_measure(igraph::page_rank(manynet::as_igraph(.data), + damping = decay)$vector, .data, measure = "pagerank centrality", - range = c(0, 1), normalization = "proportion") + range = c(0, 1), normalization = "proportional") } -#' @rdname measure_central_eigen -#' @references +#' @rdname measure_central_eigen +#' @section Hub and authority centrality: +#' Hub and authority centrality are the two halves of Kleinberg's HITS +#' (Hyperlink-Induced Topic Search) algorithm, and are computed together: +#' good authorities are pointed to by good hubs, and good hubs point to good +#' authorities. `node_by_hub()` and `node_by_authority()` return one each. +#' In an undirected network the two coincide. +#' @references #' ## On hub and authority centrality #' Kleinberg, Jon. 1999. -#' "Authoritative sources in a hyperlinked environment". +#' "Authoritative sources in a hyperlinked environment". #' _Journal of the ACM_ 46(5): 604–632. #' \doi{10.1145/324133.324140} -#' @export +#' @export node_by_authority <- function(.data, scaled = TRUE){ .data <- manynet::expect_nodes(.data) out <- igraph::hits_scores(manynet::as_igraph(.data), scale = scaled)$authority @@ -271,38 +305,92 @@ node_by_hub <- function(.data, scaled = TRUE){ normalization = `if`(scaled, "scaled", "none")) } -#' @rdname measure_central_eigen +#' @rdname measure_central_eigen +#' @template param_decay +#' @param method Character string indicating which closed walks to count. +#' By default `"all"`, which is subgraph centrality as usually defined. +#' `"odd"` counts only walks of odd length and `"even"` only those of even +#' length; the two sum to `"all"`. +#' Odd closed walks cannot occur within a bipartite structure, so a node +#' scoring near zero on `"odd"` sits in a locally two-mode-like neighbourhood. +#' See [net_by_bipartivity()] for the network-level counterpart. #' @section Subgraph centrality: #' Subgraph centrality measures the participation of a node in all subgraphs #' in the network, giving higher weight to smaller subgraphs. #' It is defined as: -#' \deqn{C_S(i) = \sum_{k=0}^{\infty} \frac{(A^k)_{ii}}{k!}} +#' \deqn{C_S(i) = \sum_{k=0}^{\infty} \frac{\delta^k (A^k)_{ii}}{k!}} #' where \eqn{(A^k)_{ii}} is the \eqn{i}th diagonal element of the \eqn{k}th power #' of the adjacency matrix \eqn{A}, representing the number of closed walks #' of length \eqn{k} starting and ending at node \eqn{i}. #' Weighting by \eqn{\frac{1}{k!}} ensures that shorter walks contribute more #' to the centrality score than longer walks. -#' +#' The `decay` parameter \eqn{\delta} tunes that further, discounting each +#' step by a further factor: at the default of 1 the measure takes its usual +#' form, and lower values concentrate it on ever shorter walks. +#' #' Subgraph centrality is a good choice of measure when the focus is on #' local connectivity and clustering around a node, #' as it captures the extent to which a node is embedded in tightly-knit #' groups within the network. #' Note though that because of the way spectral decomposition is used to #' calculate this measure, this is not a good measure for very large graphs. +#' +#' Summing these scores over all nodes gives the network's _Estrada index_, +#' so a node's subgraph centrality is its contribution to that index. #' @references #' ## On subgraph centrality #' Estrada, Ernesto and Rodríguez-Velázquez, Juan A. 2005. #' "Subgraph centrality in complex networks". #' _Physical Review E_ 71(5): 056103. #' \doi{10.1103/PhysRevE.71.056103} -#' @export -node_by_subgraph <- function(.data){ +#' +#' ## On odd and even closed walks +#' Estrada, Ernesto and Rodríguez-Velázquez, Juan A. 2005. +#' "Spectral measures of bipartivity in complex networks". +#' _Physical Review E_ 72(4): 046105. +#' \doi{10.1103/PhysRevE.72.046105} +#' @export +node_by_subgraph <- function(.data, decay = 1, + method = c("all", "odd", "even")){ .data <- manynet::expect_nodes(.data) + method <- match.arg(method) + decay <- check_decay(decay) + out <- .closed_walks(.data, decay, method) # Subgraph centrality grows exponentially in the number of closed walks and # has no theoretical maximum, so no normalisation is offered. - make_node_measure(igraph::subgraph_centrality(manynet::as_igraph(.data)), - .data, measure = "subgraph centrality", - range = c(0, Inf), normalization = "none") + # Every node has one closed walk of length zero, itself, which the "odd" + # count alone excludes. + make_node_measure(out, .data, + measure = switch(method, + all = "subgraph centrality", + odd = "odd subgraph centrality", + even = "even subgraph centrality"), + range = `if`(method == "odd", c(0, Inf), c(1, Inf)), + normalization = "none", variant = method) +} + +# Counts each node's closed walks, weighting a walk of length k by +# `decay^k / k!`, which the eigendecomposition of a symmetric adjacency matrix +# evaluates in closed form: `exp` sums walks of every length, while `sinh` and +# `cosh` split that sum into the odd- and even-length walks respectively. +# Shared by `node_by_subgraph()` and `net_by_bipartivity()`. +# Unlike `igraph::subgraph_centrality()` this honours tie weights, which are +# carried by the adjacency matrix itself. +.closed_walks <- function(.data, decay = 1, method = c("all", "odd", "even")) { + method <- match.arg(method) + mat <- manynet::as_matrix(manynet::to_multilevel(.data)) + if(!isSymmetric(unname(mat))) { + manynet::snet_info("Counting closed walks on the undirected form of this network, since the decomposition requires a symmetric matrix.") + mat <- (mat + t(mat))/2 + } + eig <- eigen(mat, symmetric = TRUE) + weights <- switch(method, + all = exp(decay * eig$values), + odd = sinh(decay * eig$values), + even = cosh(decay * eig$values)) + out <- as.numeric((eig$vectors^2) %*% weights) + names(out) <- rownames(mat) + out } #' @rdname measure_central_eigen @@ -364,7 +452,7 @@ NULL #' @export tie_by_eigenvector <- function(.data, normalized = TRUE){ .data <- manynet::expect_ties(.data) - edge_adj <- manynet::to_ties(.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", diff --git a/R/measure_change.R b/R/measure_change.R index 88f71f8..978a021 100644 --- a/R/measure_change.R +++ b/R/measure_change.R @@ -20,7 +20,9 @@ net_by_waves <- function(.data){ chg_waves <- (max(chltime)+1) - max(min(chltime)-1, 0) } else chg_waves <- 1 make_network_measure(max(tie_waves, chg_waves), - .data, call = deparse(sys.call())) + .data, call = deparse(sys.call()), + measure = "waves", range = c(1, Inf), + normalization = "none") } # Change motifs #### diff --git a/R/measure_closure.R b/R/measure_closure.R index f58b28a..5b5b6b8 100644 --- a/R/measure_closure.R +++ b/R/measure_closure.R @@ -35,10 +35,16 @@ NULL #' @examples #' net_by_reciprocity(ison_southern_women) #' @export -net_by_reciprocity <- function(.data, method = "default") { +net_by_reciprocity <- function(.data, method = c("default", "ratio")) { .data <- manynet::expect_nodes(.data) - make_network_measure(igraph::reciprocity(manynet::as_igraph(.data), mode = method), - .data, call = deparse(sys.call())) + method <- match.arg(method) + # Both methods return a proportion in [0,1], but of different things: the + # default is the share of ties that are reciprocated, the ratio the share of + # dyads that are mutual rather than asymmetric. The variant says which. + make_network_measure(igraph::reciprocity(manynet::as_igraph(.data), mode = method), + .data, call = deparse(sys.call()), + measure = "reciprocity", range = c(0, 1), + normalization = "normalized", variant = method) } #' @rdname measure_closure @@ -48,8 +54,10 @@ net_by_reciprocity <- function(.data, method = "default") { #' @export net_by_transitivity <- function(.data) { .data <- manynet::expect_nodes(.data) - make_network_measure(igraph::transitivity(manynet::as_igraph(.data)), - .data, call = deparse(sys.call())) + make_network_measure(igraph::transitivity(manynet::as_igraph(.data)), + .data, call = deparse(sys.call()), + measure = "transitivity", range = c(0, 1), + normalization = "normalized") } #' @rdname measure_closure @@ -83,14 +91,17 @@ net_by_cyclicality <- function(.data) { denom <- sum(twopaths) # closed cyclically where a tie runs back from k to i out <- if(denom == 0) NaN else sum(twopaths * t(mat))/denom - make_network_measure(out, .data, call = deparse(sys.call())) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "cyclicality", range = c(0, 1), + normalization = "normalized") } #' @rdname measure_closure #' @section Equivalency: -#' The `net_by_equivalency()` function calculates the Robins and Alexander (2004) +#' The `net_by_equivalency()` function calculates the Robins and Alexander (2004) #' clustering coefficient for two-mode networks. -#' Note that for weighted two-mode networks, the result is divided by the average tie weight. +#' The coefficient is a proportion of three-paths, and so is defined on +#' binary data; weighted networks are dichotomised before it is calculated. #' @references #' ## On equivalency or four-cycles #' Robins, Garry L, and Malcolm Alexander. 2004. @@ -102,8 +113,10 @@ net_by_cyclicality <- function(.data) { #' @export net_by_equivalency <- function(.data) { .data <- manynet::expect_nodes(.data) + if(manynet::is_weighted(.data)) + manynet::snet_info("Using the unweighted form of the network.") if(manynet::is_twomode(.data)){ - mat <- manynet::as_matrix(.data) + mat <- manynet::as_matrix(manynet::to_unweighted(.data)) c <- ncol(mat) indegrees <- colSums(mat) twopaths <- crossprod(mat) @@ -113,7 +126,6 @@ net_by_equivalency <- function(.data) { sum(twopaths * (matrix(indegrees, c, c) - twopaths))) if (is.nan(out)) out <- 1 - if(manynet::is_weighted(.data)) out <- out / mean(mat[mat>0]) } else { out <- rowSums(vapply(manynet::snet_progress_nodes(.data), function(i){ threepaths <- igraph::all_simple_paths(.data, i, cutoff = 3, @@ -127,7 +139,9 @@ net_by_equivalency <- function(.data) { }, FUN.VALUE = numeric(2))) out <- out[1]/out[2] } - make_network_measure(out, .data, call = deparse(sys.call())) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "equivalency", range = c(0, 1), + normalization = "normalized") } #' @rdname measure_closure @@ -164,7 +178,9 @@ net_by_congruency <- function(.data, object2){ sum(twopaths * (matrix(degrees, connects, connects) - twopaths))) if (is.nan(output)) output <- 1 - make_network_measure(output, .data, call = deparse(sys.call())) + make_network_measure(output, .data, call = deparse(sys.call()), + measure = "congruency", range = c(0, 1), + normalization = "normalized") } # Nodal closure #### @@ -192,33 +208,54 @@ NULL #' @rdname measure_closure_node #' @examples -#' node_by_reciprocity(to_unweighted(ison_networkers)) +#' node_by_reciprocity(ison_networkers) #' @export node_by_reciprocity <- function(.data) { .data <- manynet::expect_nodes(.data) - out <- manynet::as_matrix(.data) - make_node_measure(rowSums(out * t(out))/rowSums(out), - .data) + if(manynet::is_weighted(.data)) + manynet::snet_info("Using the unweighted form of the network.") + # A proportion of a node's ties that are returned, so counts of ties rather + # than sums of weights: otherwise a reciprocated tie of weight 3 scores 3. + out <- manynet::as_matrix(manynet::to_unweighted(.data)) + make_node_measure(rowSums(out * t(out))/rowSums(out), + .data, measure = "reciprocity", range = c(0, 1), + normalization = "normalized") } -#' @rdname measure_closure_node +#' @rdname measure_closure_node +#' @section Node transitivity: +#' A node's transitivity is the proportion of its neighbours that are +#' themselves connected, which is also known as the _local clustering +#' coefficient_ of the node. +#' @references +#' ## On the local clustering coefficient +#' Watts, Duncan J., and Steven H. Strogatz. 1998. +#' "Collective dynamics of 'small-world' networks". +#' _Nature_ 393(6684): 440-442. +#' \doi{10.1038/30918} +#' +#' Holland, Paul W., and Samuel Leinhardt. 1971. +#' "Transitivity in structural models of small groups". +#' _Comparative Group Studies_ 2(2): 107-124. +#' \doi{10.1177/104649647100200201} #' @examples #' node_by_transitivity(ison_adolescents) #' @export node_by_transitivity <- function(.data) { .data <- manynet::expect_nodes(.data) make_node_measure(igraph::transitivity(manynet::as_igraph(.data), - type = "local"), - .data) + type = "local"), + .data, measure = "transitivity", range = c(0, 1), + normalization = "normalized") } #' @rdname measure_closure_node #' @export node_by_equivalency <- function(.data) { .data <- manynet::expect_nodes(.data) - # if(is_weighted(.data)) - # snet_info("Using unweighted form of the network.") - out <- vapply(manynet::snet_progress_seq(.data), function(i){ + if(manynet::is_weighted(.data)) + manynet::snet_info("Using the unweighted form of the network.") + out <- vapply(manynet::snet_progress_nodes(.data), function(i){ threepaths <- igraph::all_simple_paths(.data, i, cutoff = 3, mode = "all") onepaths <- threepaths[vapply(threepaths, length, @@ -228,6 +265,7 @@ node_by_equivalency <- function(.data) { mean(sapply(threepaths,"[[",4) %in% sapply(onepaths,"[[",2)) }, FUN.VALUE = numeric(1)) if (any(is.nan(out))) out[is.nan(out)] <- 0 - make_node_measure(out, .data) + make_node_measure(out, .data, measure = "equivalency", range = c(0, 1), + normalization = "normalized") } diff --git a/R/measure_cohesion.R b/R/measure_cohesion.R index ffe51df..e714003 100644 --- a/R/measure_cohesion.R +++ b/R/measure_cohesion.R @@ -28,12 +28,16 @@ NULL net_by_density <- function(.data) { .data <- manynet::expect_nodes(.data) if (manynet::is_twomode(.data)) { - mat <- manynet::as_matrix(.data) + # counting ties rather than summing weights, so that the two-mode branch + # stays a ratio of ties to possible ties, as the one-mode branch is + mat <- manynet::as_matrix(manynet::to_unweighted(.data)) out <- sum(mat) / (nrow(mat) * ncol(mat)) } else { out <- igraph::edge_density(manynet::as_igraph(.data)) } - make_network_measure(out, .data, call = deparse(sys.call())) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "density", range = c(0, 1), + normalization = "normalized") } #' @rdname measure_cohesion @@ -82,7 +86,9 @@ net_by_compactness <- function(.data) { recip[!is.finite(recip)] <- 0 # unreachable pairs contribute nothing n <- manynet::net_nodes(.data) out <- if(n < 2) NaN else sum(recip)/(n*(n-1)) - make_network_measure(out, .data, call = deparse(sys.call())) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "compactness", range = c(0, 1), + normalization = "normalized") } #' @rdname measure_cohesion @@ -98,7 +104,9 @@ net_by_components <- function(.data){ .data <- manynet::expect_nodes(.data) object <- manynet::as_igraph(.data) make_network_measure(igraph::components(object, mode = "strong")$no, - object, call = deparse(sys.call())) + object, call = deparse(sys.call()), + measure = "number of components", range = c(1, Inf), + normalization = "none") } #' @rdname measure_cohesion @@ -113,7 +121,9 @@ net_by_independence <- function(.data){ } else { out <- igraph::ivs_size(manynet::to_undirected(manynet::as_igraph(.data))) } - make_network_measure(out, .data, call = deparse(sys.call())) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "independence number", range = c(1, Inf), + normalization = "none") } # Breadth #### @@ -140,9 +150,11 @@ NULL net_by_diameter <- function(.data){ .data <- manynet::expect_nodes(.data) object <- manynet::as_igraph(.data) - make_network_measure(igraph::diameter(object, + make_network_measure(igraph::diameter(object, directed = manynet::is_directed(object)), - object, call = deparse(sys.call())) + object, call = deparse(sys.call()), + measure = "diameter", range = c(0, Inf), + normalization = "none") } #' @rdname measure_breadth @@ -156,7 +168,9 @@ net_by_length <- function(.data){ object <- manynet::as_igraph(.data) make_network_measure(igraph::mean_distance(object, directed = manynet::is_directed(object)), - object, call = deparse(sys.call())) + object, call = deparse(sys.call()), + measure = "average path length", range = c(0, Inf), + normalization = "none") } # Fragmentation #### @@ -195,8 +209,10 @@ NULL #' @export net_by_cohesion <- function(.data){ .data <- manynet::expect_nodes(.data) - make_network_measure(igraph::cohesion(manynet::as_igraph(.data)), - .data, call = deparse(sys.call())) + make_network_measure(igraph::cohesion(manynet::as_igraph(.data)), + .data, call = deparse(sys.call()), + measure = "node connectivity", range = c(0, Inf), + normalization = "none") } #' @rdname measure_fragmentation @@ -207,8 +223,10 @@ net_by_cohesion <- function(.data){ #' @export net_by_adhesion <- function(.data){ .data <- manynet::expect_nodes(.data) - make_network_measure(igraph::adhesion(manynet::as_igraph(.data)), - .data, call = deparse(sys.call())) + make_network_measure(igraph::adhesion(manynet::as_igraph(.data)), + .data, call = deparse(sys.call()), + measure = "tie connectivity", range = c(0, Inf), + normalization = "none") } #' @rdname measure_fragmentation @@ -221,11 +239,13 @@ net_by_strength <- function(.data){ 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)) - make_network_measure(min(out), .data, call = deparse(sys.call())) + make_network_measure(min(out), .data, call = deparse(sys.call()), + measure = "strength", range = c(0, Inf), + normalization = "none") } -#' @rdname measure_fragmentation -#' @examples +#' @rdname measure_fragmentation +#' @examples #' net_by_toughness(ison_adolescents) #' @export net_by_toughness <- function(.data){ @@ -234,6 +254,8 @@ net_by_toughness <- function(.data){ 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)) - make_network_measure(min(out), .data, call = deparse(sys.call())) + make_network_measure(min(out), .data, call = deparse(sys.call()), + measure = "toughness", range = c(0, Inf), + normalization = "none") } diff --git a/R/measure_diffusion.R b/R/measure_diffusion.R index fc60741..a4be40c 100644 --- a/R/measure_diffusion.R +++ b/R/measure_diffusion.R @@ -61,9 +61,15 @@ net_by_transmissibility <- function(.data){ if(inherits(.data, "diff_model")) net <- attr(.data, "network") else net <- .data + # Read as a proportion, but the at-risk denominator `s` is recorded at the + # end of each period rather than the start, so a period in which more nodes + # were infected than were left at risk can push the ratio above 1. Declared + # open above rather than claiming a bound the values can break. make_network_measure(mean(out, na.rm = TRUE), net, - call = deparse(sys.call())) + call = deparse(sys.call()), + measure = "transmissibility", range = c(0, Inf), + normalization = "none") } #' @rdname measure_diffusion_net @@ -92,7 +98,9 @@ net_by_recovery <- function(.data, censor = TRUE){ net <- .data make_network_measure(mean(recovs, na.rm = TRUE), net, - call = deparse(sys.call())) + call = deparse(sys.call()), + measure = "recovery time", range = c(0, Inf), + normalization = "none") } #' @rdname measure_diffusion_net @@ -140,7 +148,9 @@ net_by_reproduction <- function(.data){ (1/net_by_recovery(.data)) out <- min(out, mean(node_by_deg(net))) make_network_measure(out, net, - call = deparse(sys.call())) + call = deparse(sys.call()), + measure = "reproduction number", range = c(0, Inf), + normalization = "none") } #' @rdname measure_diffusion_net @@ -165,6 +175,11 @@ net_by_reproduction <- function(.data){ #' would need to be vaccinated or otherwise protected to achieve herd immunity. #' To identify how many nodes this would be, multiply this proportion with the number #' of nodes in the network. +#' +#' Where \eqn{R < 1} the diffusion is already sub-critical and dies out of its +#' own accord, so no one needs protecting and the threshold is reported as 0. +#' The formula would otherwise return a negative proportion, which has no +#' interpretation. #' @references #' ## On herd immunity #' Garnett, G.P. 2005. @@ -182,10 +197,15 @@ net_by_immunity <- function(.data, normalized = TRUE){ if(inherits(.data, "diff_model")) net <- attr(.data, "network") else net <- .data - out <- 1 - 1/net_by_reproduction(.data) + # Below the epidemic threshold the formula turns negative; no one needs + # protecting from a diffusion that cannot sustain itself. + out <- max(1 - 1/net_by_reproduction(.data), 0) if(!normalized) out <- ceiling(out * manynet::net_nodes(net)) make_network_measure(out, net, - call = deparse(sys.call())) + call = deparse(sys.call()), + measure = "herd immunity threshold", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } # net_infection #### @@ -226,25 +246,34 @@ net_by_infection_complete <- function(.data){ net <- attr(.data, "network") else net <- .data make_network_measure(out, net, - call = deparse(sys.call())) + call = deparse(sys.call()), + measure = "time to complete infection", + range = c(1, Inf), normalization = "none") } -#' @rdname measure_diffusion_infection +#' @rdname measure_diffusion_infection #' @examples #' net_by_infection_total(smeg_diff) #' @export net_by_infection_total <- function(.data, normalized = TRUE){ + # Normalised against the number of nodes rather than a theoretical maximum: + # where reinfection is possible a node can be counted more than once, so the + # proportion is not capped at 1, and the declared range stays open above. if(inherits(.data, "diff_model")){ diff_model <- manynet::as_diffusion(.data) out <- sum(diff_model$I_new) if(normalized) out <- out / diff_model$n[length(diff_model$n)] make_network_measure(out, attr(diff_model, "network"), - call = deparse(sys.call())) + call = deparse(sys.call()), + measure = "total infections", range = c(0, Inf), + normalization = `if`(normalized, "normalized", "none")) } else { out <- sum(manynet::as_changelist(.data)$value == "I") if(normalized) out <- out / manynet::net_nodes(.data) make_network_measure(out, .data, - call = deparse(sys.call())) + call = deparse(sys.call()), + measure = "total infections", range = c(0, Inf), + normalization = `if`(normalized, "normalized", "none")) } } @@ -259,7 +288,9 @@ net_by_infection_peak <- function(.data){ net <- .data out <- which(diff_model$I_new == max(diff_model$I_new))[1] make_network_measure(out, net, - call = deparse(sys.call())) + call = deparse(sys.call()), + measure = "time to peak infection", + range = c(1, Inf), normalization = "none") } # node_diffusion #### @@ -341,7 +372,8 @@ node_by_adopt_time <- function(.data){ } if(!manynet::is_labelled(net)) out <- unname(out) - make_node_measure(out, net) + make_node_measure(out, net, measure = "adoption time", range = c(0, Inf), + normalization = "none") } #' @rdname measure_diffusion_node @@ -422,7 +454,9 @@ node_by_adopt_threshold <- function(.data, normalized = TRUE, lag = 1){ out <- unname(out[order(as.numeric(names(out)))]) } if(normalized) out <- out / node_by_deg(net) - make_node_measure(out, net) + make_node_measure(out, net, measure = "adoption threshold", + range = `if`(normalized, c(0, 1), c(0, Inf)), + normalization = `if`(normalized, "normalized", "none")) } #' @rdname measure_diffusion_node @@ -457,7 +491,8 @@ node_by_adopt_recovery <- function(.data){ NA), FUN.VALUE = numeric(1)) } - make_node_measure(out, net) + make_node_measure(out, net, measure = "recovery time", range = c(0, Inf), + normalization = "none") } #' @rdname measure_diffusion_node @@ -522,7 +557,8 @@ node_by_adopt_exposure <- function(.data, mark, time = 0){ out <- rep(0, manynet::net_nodes(.data)) out[as.numeric(names(tabcontact))] <- unname(tabcontact) } - make_node_measure(out, .data) + make_node_measure(out, .data, measure = "exposure", range = c(0, Inf), + normalization = "none") } # Diffusion membership #### diff --git a/R/measure_features.R b/R/measure_features.R index 0f813b4..dba9315 100644 --- a/R/measure_features.R +++ b/R/measure_features.R @@ -14,11 +14,13 @@ #' - `net_by_scalefree()` measures the exponent of a fitted #' power-law distribution. An exponent between 2 and 3 usually indicates #' a power-law distribution. -#' - `net_by_balance()` measures the structural balance index on +#' - `net_by_balance()` measures the structural balance index on #' the proportion of balanced triangles, -#' ranging between `0` if all triangles are imbalanced and +#' ranging between `0` if all triangles are imbalanced and #' `1` if all triangles are balanced. -#' +#' - `net_by_bipartivity()` measures how close a network is to being +#' bipartite, that is, to dividing into two sets with ties only between them. +#' #' @template param_data #' @family features #' @template net_measure @@ -73,7 +75,9 @@ net_by_richclub <- function(.data){ if(length(which(coefs == 1)) == 0) out <- 0 else out <- coefs[.elbow_finder(seq_along(coefs), coefs)] # max(coefs, na.rm = TRUE) - make_network_measure(out, .data, call = deparse(sys.call())) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "rich-club coefficient", range = c(0, 1), + normalization = "normalized") } #' @rdname measure_features #' @param times Integer of number of simulations. @@ -90,14 +94,15 @@ net_by_richclub <- function(.data){ #' \deqn{\frac{L_r}{L} - \frac{C}{C_l}}, #' where \eqn{C_l} is the clustering coefficient for a lattice graph #' with the same dimensions. -#' \eqn{\omega} ranges between 0 and 1, -#' where 1 is as close to a small-world as possible. +#' \eqn{\omega} ranges between -1 and 1, where values close to 0 are +#' as close to a small-world as possible; negative values indicate a +#' lattice-like network, and positive values a more random one. #' - "SWI" is an alternative proposed by Neal (2017), #' \deqn{\frac{L - L_l}{L_r - L_l} \times \frac{C - C_r}{C_l - C_r}}, #' where \eqn{L_l} is the average path length for a lattice graph #' with the same dimensions. -#' \eqn{SWI} also ranges between 0 and 1 with the same interpretation, -#' but where there may not be a network for which \eqn{SWI = 1}. +#' \eqn{SWI} ranges between 0 and 1, where 1 is as close to a small-world +#' as possible, though there may not be a network for which \eqn{SWI = 1}. #' @seealso [net_by_transitivity()] and [net_by_equivalency()] #' for how clustering is calculated #' @references @@ -158,7 +163,16 @@ net_by_smallworld <- function(.data, "sigma" = (co/cr)/(lo/lr), "SWI" = ((lo - ll)/(lr - ll))*((co - cr)/(cl - cr))) make_network_measure(out, - .data, call = deparse(sys.call())) + .data, call = deparse(sys.call()), + measure = "small-world coefficient", + range = switch(method, + omega = c(-1, 1), + sigma = c(0, Inf), + SWI = c(0, 1)), + # Only SWI is a proportion of a theoretical maximum; + # omega is signed and sigma is an unbounded ratio. + normalization = `if`(method == "SWI", "normalized", "none"), + variant = method) } #' @rdname measure_features #' @importFrom igraph fit_power_law @@ -195,7 +209,9 @@ net_by_scalefree <- function(.data){ manynet::snet_info("Note: Kolmogorov-Smirnov test that data could have been drawn", "from a power-law distribution rejected.") make_network_measure(out$alpha, .data, - call = deparse(sys.call())) + call = deparse(sys.call()), + measure = "power-law exponent", range = c(1, Inf), + normalization = "none") } #' @rdname measure_features #' @section Bipartivity: @@ -312,7 +328,9 @@ net_by_balance <- function(.data) { tria_count <- .count_signed_triangles(g) make_network_measure(unname((tria_count["+++"] + tria_count["+--"])/sum(tria_count)), .data, - call = deparse(sys.call())) + call = deparse(sys.call()), + measure = "structural balance", range = c(0, 1), + normalization = "normalized") } # Structural fit #### @@ -346,7 +364,7 @@ net_by_balance <- function(.data) { #' | --- | --- | --- | --- | #' | `net_by_core()` | a core-periphery model | -1 to 1 | higher | #' | `net_by_factions()` | a components model | -1 to 1 | higher | -#' | `net_by_modularity()` | the partition's communities | -0.5 to 1 | higher | +#' | `net_by_modularity()` | the partition's communities | -0.5 to 1 (at the default resolution) | higher | #' | `net_by_inconsistency()` | ideal block types | 0 upwards | **lower** | #' #' Compare partitions using one measure at a time. @@ -415,7 +433,19 @@ net_by_core <- function(.data, out <- (diff1 + diff2) * sqrt(sum(mark)) } } else manynet::snet_unavailable(method) - make_network_measure(out, .data, call = deparse(sys.call())) + # The methods are on genuinely different scales: a correlation, a Euclidean + # distance, and two signed differences in coreness, so each declares its own. + make_network_measure(out, .data, call = deparse(sys.call()), + measure = switch(method, + correlation = "core-periphery correlation", + ident = "core-periphery distance", + ndiff = "normalised core-periphery difference", + diff = "core-periphery difference"), + range = switch(method, + correlation = c(-1, 1), + ident = c(0, Inf), + ndiff = , diff = c(-Inf, Inf)), + normalization = "none", variant = method) } #' @rdname measure_fit @@ -434,16 +464,20 @@ net_by_factions <- function(.data, out <- stats::cor(c(manynet::as_matrix(.data)), c(manynet::as_matrix(manynet::create_components(.data, membership = membership)))) - make_network_measure(out, .data, call = deparse(sys.call())) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = "factional correlation", range = c(-1, 1), + normalization = "none") } #' @rdname measure_fit #' @section Modularity: #' Modularity measures the difference between the number of ties within each community #' from the number of ties expected within each community in a random graph -#' with the same degrees, and ranges between -1 and +1. -#' Modularity scores of +1 mean that ties only appear within communities, -#' while -1 would mean that ties only appear between communities. +#' with the same degrees. At the default `resolution` it ranges between +#' -0.5 and +1; a higher resolution can push it further below that floor. +#' Modularity scores approaching +1 mean that ties only appear within +#' communities, while negative scores mean that ties appear between +#' communities more often than chance would predict. #' A score of 0 would mean that ties are half within and half between communities, #' as one would expect in a random graph. #' @@ -495,11 +529,17 @@ net_by_modularity <- function(.data, make_network_measure(igraph::modularity(manynet::to_multilevel(.data), membership = membership, resolution = resolution), - .data, call = deparse(sys.call())) + .data, call = deparse(sys.call()), + measure = "modularity", + range = `if`(resolution == 1, c(-0.5, 1), c(-Inf, 1)), + normalization = "none") } else make_network_measure(igraph::modularity(.data, membership = membership, resolution = resolution), - .data, call = deparse(sys.call())) + .data, call = deparse(sys.call()), + measure = "modularity", + range = `if`(resolution == 1, c(-0.5, 1), c(-Inf, 1)), + normalization = "none") } #' @rdname measure_fit @@ -601,7 +641,9 @@ net_by_inconsistency <- function(.data, membership = NULL, FUN.VALUE = numeric(1))) } cells <- if(loops) length(mat) else length(mat) - nrow(mat) - make_network_measure(total/cells, .data, call = deparse(sys.call())) + make_network_measure(total/cells, .data, call = deparse(sys.call()), + measure = "blockmodel inconsistency", range = c(0, Inf), + normalization = "none") } # Resolve the vocabulary permitted at block position (i,j), which is either diff --git a/R/measure_heterogeneity.R b/R/measure_heterogeneity.R index 7f8bc2c..a2d612c 100644 --- a/R/measure_heterogeneity.R +++ b/R/measure_heterogeneity.R @@ -39,7 +39,9 @@ NULL net_by_richness <- function(.data, attribute){ .data <- manynet::expect_nodes(.data) make_network_measure(length(unique(manynet::node_attribute(.data, attribute))), - .data, call = deparse(sys.call())) + .data, call = deparse(sys.call()), + measure = "richness", range = c(1, Inf), + normalization = "none") } #' @rdname measure_diverse_net @@ -153,7 +155,11 @@ net_by_diversity <- function(.data, attribute, teachman = teachman(attr), variation = cv(attr), gini = gini(attr)) - make_network_measure(out, .data, call = deparse(sys.call())) + meta <- .diversity_metadata(diversity) + make_network_measure(out, .data, call = deparse(sys.call()), + measure = meta$measure, range = meta$range, + normalization = meta$normalization, + variant = meta$variant) } # Nodal diversity #### @@ -186,7 +192,10 @@ node_by_richness <- function(.data, attribute){ out <- vapply(manynet::to_egos(.data, min_dist = 1), function(x) length(unique(manynet::node_attribute(x, attribute))), FUN.VALUE = numeric(1)) - make_node_measure(out, .data) + # An isolate is connected to no categories at all, so unlike the whole + # network's richness this can be 0. + make_node_measure(out, .data, measure = "richness", range = c(0, Inf), + normalization = "none") } #' @rdname measure_diverse_node @@ -201,7 +210,7 @@ node_by_diversity <- function(.data, attribute, attr <- manynet::node_attribute(.data, attribute) diversity <- match.arg(diversity) if(is.numeric(attr) && diversity %in% c("blau","teachman")){ - manynet::snet_info("{.val {method}} index is not appropriate for numeric attributes.") + manynet::snet_info("{.val {diversity}} index is not appropriate for numeric attributes.") manynet::snet_info("Using {.val variation} coefficient instead", "({.val gini} coefficient also available).") diversity <- "variation" @@ -217,7 +226,9 @@ node_by_diversity <- function(.data, attribute, igraph::induced_subgraph(manynet::as_igraph(.data), x), attribute, diversity = diversity), FUN.VALUE = numeric(1)) - make_node_measure(out, .data) + meta <- .diversity_metadata(diversity) + make_node_measure(out, .data, measure = meta$measure, range = meta$range, + normalization = meta$normalization, variant = meta$variant) } # Network assortativity #### @@ -322,7 +333,9 @@ net_by_heterophily <- function(.data, attribute){ nInternal <- sum(m * same, na.rm = TRUE) nExternal <- sum(m, na.rm = TRUE) - nInternal ei <- (nExternal - nInternal) / sum(m, na.rm = TRUE) - make_network_measure(ei, .data, call = deparse(sys.call())) + make_network_measure(ei, .data, call = deparse(sys.call()), + measure = "E-I index", range = c(-1, 1), + normalization = "none") } #' @rdname measure_assort_net @@ -410,7 +423,44 @@ net_by_homophily <- function(.data, attribute, yule = yule(m, attribute), geary = geary(m, attribute)) - make_network_measure(res, .data, call = deparse(sys.call())) + meta <- .homophily_metadata(assortativity) + make_network_measure(res, .data, call = deparse(sys.call()), + measure = meta$measure, range = meta$range, + normalization = "none", variant = meta$variant) +} + +# As with diversity, the index asked for may not be the index used, so the +# metadata follows the resolved choice. Geary's C is centred on 1 rather than +# 0, and runs the other way: below 1 is similarity, above 1 dissimilarity. +# Both diversity functions may substitute a different index than the one asked +# for, so the metadata is read off the index that actually ran. Blau's and +# Gini's are bounded proportions; Teachman's entropy grows with the number of +# categories, and the coefficient of variation is signed and unbounded. +.diversity_metadata <- function(diversity){ + list(measure = switch(diversity, + blau = "Blau's index", + teachman = "Teachman's index", + variation = "coefficient of variation", + gini = "Gini coefficient"), + range = switch(diversity, + blau = , gini = c(0, 1), + teachman = c(0, Inf), + variation = c(-Inf, Inf)), + normalization = `if`(diversity %in% c("blau", "gini"), + "normalized", "none"), + variant = diversity) +} + +.homophily_metadata <- function(assortativity){ + list(measure = switch(assortativity, + ie = "IE index", + ei = "E-I index", + yule = "Yule's Q", + geary = "Geary's C"), + range = switch(assortativity, + ie = , ei = , yule = c(-1, 1), + geary = c(0, 2)), + variant = assortativity) } @@ -438,7 +488,9 @@ net_by_assortativity <- function(.data){ .data <- manynet::expect_nodes(.data) make_network_measure(igraph::assortativity_degree(manynet::as_igraph(.data), directed = manynet::is_directed(.data)), - .data, call = deparse(sys.call())) + .data, call = deparse(sys.call()), + measure = "degree assortativity", range = c(-1, 1), + normalization = "none") } #' @rdname measure_assort_net @@ -450,6 +502,14 @@ net_by_assortativity <- function(.data){ #' \doi{10.2307/2332142} #' @examples #' net_by_spatial(ison_lawfirm, "age") +#' @section Spatial autocorrelation: +#' Moran's I is conventionally read on \eqn{[-1, 1]}, where positive values +#' indicate that tied nodes hold similar values and negative values that they +#' hold dissimilar ones. Its actual bounds, however, are set by the +#' eigenvalues of the weight matrix, and on the unstandardised weights used +#' here it can fall outside that interval. Its range is therefore declared +#' open at both ends, and the conventional interval read as a guide rather +#' than a guarantee. #' @export net_by_spatial <- function(.data, attribute){ .data <- manynet::expect_nodes(.data) @@ -463,7 +523,9 @@ net_by_spatial <- function(.data, attribute){ (sum(w * matrix(x - x_bar, N, N) * matrix(x - x_bar, N, N, byrow = TRUE)) / sum((x - x_bar)^2)) make_network_measure(I, .data, - call = deparse(sys.call())) + call = deparse(sys.call()), + measure = "Moran's I", range = c(-Inf, Inf), + normalization = "none") } # Network assortativity #### @@ -509,7 +571,8 @@ node_by_heterophily <- function(.data, attribute){ nInternal[is.na(attribute)] <- NA nExternal <- rowSums(m, na.rm = TRUE) - nInternal ei <- (nExternal - nInternal) / rowSums(m, na.rm = TRUE) - make_node_measure(ei, .data) + make_node_measure(ei, .data, measure = "E-I index", range = c(-1, 1), + normalization = "none") } #' @rdname measure_assort_node @@ -541,6 +604,8 @@ node_by_homophily <- function(.data, attribute, subattr, assortativity = assortativity) }, FUN.VALUE = numeric(1)) - make_node_measure(out, .data) + meta <- .homophily_metadata(assortativity) + make_node_measure(out, .data, measure = meta$measure, range = meta$range, + normalization = "none", variant = meta$variant) } diff --git a/R/measure_hierarchy.R b/R/measure_hierarchy.R index 1c8845f..de959e8 100644 --- a/R/measure_hierarchy.R +++ b/R/measure_hierarchy.R @@ -86,18 +86,39 @@ net_by_connectedness <- function(.data){ .data <- manynet::expect_nodes(.data) dists <- igraph::distances(manynet::as_igraph(.data)) make_network_measure(1 - sum(dists==Inf)/sum(dists!=0), - .data, - call = deparse(sys.call())) + .data, + call = deparse(sys.call()), + measure = "connectedness", range = c(0, 1), + normalization = "normalized") } -#' @rdname measure_hierarchy +#' @rdname measure_hierarchy +#' @section Efficiency: +#' A perfect hierarchy is a tree: every node but the root has exactly one +#' superior, and there are no ties to spare. Krackhardt's efficiency asks how +#' close a network comes to that, by counting the ties it carries in excess of +#' the minimum needed to hold its components together, as a proportion of the +#' most excess ties it could possibly carry: +#' \deqn{E = 1 - \frac{|E| - \sum_i (N_i - 1)}{\sum_i \left(M_i - (N_i - 1)\right)}} +#' where \eqn{N_i} is the size of weak component \eqn{i} and \eqn{M_i} the +#' number of ties possible within it. A tree or forest scores 1, and a +#' complete network 0. #' @export net_by_efficiency <- function(.data) { .data <- manynet::expect_nodes(.data) - degs <- node_by_indegree(.data, normalized = FALSE) - out <- (manynet::net_nodes(.data)-1)/sum(degs) - make_network_measure(out, .data, - call = deparse(sys.call())) + object <- manynet::as_igraph(.data) + comps <- igraph::components(object, mode = "weak") + sizes <- comps$csize + # Every component needs N_i - 1 ties to hold together; anything beyond that + # is excess, and efficiency is the share of possible excess left unused. + spanning <- sum(sizes - 1) + possible <- if(manynet::is_directed(object)) sizes*(sizes-1) else sizes*(sizes-1)/2 + headroom <- sum(possible - (sizes - 1)) + out <- if(headroom == 0) 1 else 1 - (manynet::net_ties(object) - spanning)/headroom + make_network_measure(out, .data, + call = deparse(sys.call()), + measure = "efficiency", range = c(0, 1), + normalization = "normalized") } #' @rdname measure_hierarchy @@ -117,6 +138,8 @@ net_by_upperbound <- function(.data) { }) out <- sum(out)/length(out) } - make_network_measure(out, .data, - call = deparse(sys.call())) + make_network_measure(out, .data, + call = deparse(sys.call()), + measure = "least upper boundedness", range = c(0, 1), + normalization = "normalized") } \ No newline at end of file diff --git a/R/measure_holes.R b/R/measure_holes.R index dde079c..9276900 100644 --- a/R/measure_holes.R +++ b/R/measure_holes.R @@ -52,7 +52,8 @@ node_by_bridges <- function(.data){ out <- vapply(igraph::V(g), function(ego){ length(igraph::E(g)[.inc(ego) & tie_is_bridge(g)==1]) }, FUN.VALUE = numeric(1)) - make_node_measure(out, .data) + make_node_measure(out, .data, measure = "bridges", range = c(0, Inf), + normalization = "none") } #' @rdname measure_broker_node @@ -77,7 +78,8 @@ node_by_redundancy <- function(.data){ } else { out <- .redund(manynet::as_matrix(.data)) } - make_node_measure(out, .data) + make_node_measure(out, .data, measure = "redundancy", range = c(0, Inf), + normalization = "none") } .redund <- function(.mat){ @@ -118,7 +120,8 @@ node_by_effsize <- function(.data){ mat <- manynet::as_matrix(.data) out <- rowSums(mat>0) - .redund(mat) } - make_node_measure(out, .data) + make_node_measure(out, .data, measure = "effective size", range = c(0, Inf), + normalization = "none") } .twopath_matrix <- function(.data){ @@ -137,7 +140,8 @@ node_by_effsize <- function(.data){ node_by_efficiency <- function(.data){ .data <- manynet::expect_nodes(.data) out <- node_by_effsize(.data) / node_by_degree(.data, normalized = FALSE) - make_node_measure(as.numeric(out), .data) + make_node_measure(as.numeric(out), .data, measure = "efficiency", + range = c(0, 1), normalization = "normalized") } #' @rdname measure_broker_node @@ -148,6 +152,12 @@ node_by_efficiency <- function(.data){ #' \doi{10.1007/s10784-019-09464-5} #' @examples #' node_by_constraint(ison_southern_women) +#' @section Constraint: +#' Constraint has a natural floor at 0, for a node whose contacts are wholly +#' unconnected to one another, but no clean ceiling: the standard result is +#' that it can reach around 1.125 for one-mode networks, and the two-mode +#' form is a different summation again. Its declared range is therefore left +#' open above rather than asserting a bound the measure can exceed. #' @export node_by_constraint <- function(.data) { .data <- manynet::expect_nodes(.data) @@ -189,7 +199,8 @@ node_by_constraint <- function(.data) { nodes = igraph::V(.data), weights = NULL) } - make_node_measure(res, .data) + make_node_measure(res, .data, measure = "constraint", range = c(0, Inf), + normalization = "none") } #' @rdname measure_broker_node @@ -210,7 +221,8 @@ node_by_hierarchy <- function(.data){ sum(rj*log(rj)) / (N * log(N)) }, FUN.VALUE = numeric(1)) out[is.nan(out)] <- 0 - make_node_measure(out, .data) + make_node_measure(out, .data, measure = "hierarchy", range = c(0, 1), + normalization = "normalized") } #' @rdname measure_broker_node @@ -225,7 +237,8 @@ node_by_neighbours_degree <- function(.data){ .data <- manynet::expect_nodes(.data) out <- igraph::knn(manynet::as_igraph(.data), mode = "out")$knn - make_node_measure(out, .data) + make_node_measure(out, .data, measure = "average neighbour degree", + range = c(0, Inf), normalization = "none") } # Tie holes #### @@ -236,6 +249,9 @@ node_by_neighbours_degree <- function(.data){ #' `tie_by_cohesion()` measures the ratio between common neighbors to ties' #' adjacent nodes and the total number of adjacent nodes, #' where high values indicate ties' embeddedness in dense local environments. +#' +#' A tie whose two endpoints have no other neighbours has nothing to be +#' embedded in, and so returns `NaN` rather than 0. #' #' @template param_data #' @family brokerage @@ -258,5 +274,6 @@ tie_by_cohesion <- function(.data){ neigh_nodes <- length(unique(c(neigh1, neigh2)))-2 shared_nodes / neigh_nodes } ) - make_tie_measure(out, .data) + make_tie_measure(out, .data, measure = "cohesion", range = c(0, 1), + normalization = "normalized") } diff --git a/R/member_core.R b/R/member_core.R index d5400a9..dd6b01b 100644 --- a/R/member_core.R +++ b/R/member_core.R @@ -111,7 +111,8 @@ node_by_kcoreness <- function(.data){ .data <- manynet::expect_nodes(.data) if(!manynet::is_graph(.data)) .data <- manynet::as_igraph(.data) out <- igraph::coreness(.data) - make_node_measure(out, .data) + make_node_measure(out, .data, measure = "k-coreness", range = c(0, Inf), + normalization = "none") } #' @rdname measure_core @@ -132,7 +133,8 @@ node_by_coreness <- function(.data) { init <- rep(0.5, n) result <- stats::optim(init, obj_fun, method = "L-BFGS-B", lower = 0, upper = 1) - make_node_measure(result$par, .data) + make_node_measure(result$par, .data, measure = "coreness", range = c(0, 1), + normalization = "none") } # Membering core #### diff --git a/R/member_equivalence.R b/R/member_equivalence.R index dca857c..dd6f446 100644 --- a/R/member_equivalence.R +++ b/R/member_equivalence.R @@ -103,8 +103,8 @@ node_in_structural <- function(.data, #' By default `"rolesim"`; `"rege"` is also available. #' Fewer, identifiable letters, e.g. `"ro"` for RoleSim, is sufficient. #' See [regularity_rolesim()] and [regularity_rege()] for how they differ. -#' @param beta A decay parameter between 0 and 1 passed to [regularity_rolesim()], -#' controlling how much weight is given to the recursive component. +#' @template param_decay +#' @param beta Deprecated; use `decay` instead. #' @section Regular equivalence: #' Two nodes are regularly equivalent if each has ties to the same _kinds_ of #' others, even where those others are not the same individuals and are not @@ -133,13 +133,14 @@ node_in_regular <- function(.data, "canberra", "binary", "minkowski"), Kmax = 8L, regularity = c("rolesim", "rege"), - beta = 0.15){ + decay = 0.15, beta = NULL){ .data <- manynet::expect_nodes(.data) regularity <- match.arg(regularity) + decay <- resolve_decay(decay, beta, "beta") manynet::snet_info("Calculating regular equivalence using", "{.fn regularity_{regularity}}.") mat <- switch(regularity, - rolesim = regularity_rolesim(.data, beta = beta), + rolesim = regularity_rolesim(.data, decay = decay), rege = regularity_rege(.data)) node_in_equivalence(.data, mat, k = k, cluster = cluster, distance = distance, Kmax = Kmax) diff --git a/R/method_regularity.R b/R/method_regularity.R index 2f2d210..cde227d 100644 --- a/R/method_regularity.R +++ b/R/method_regularity.R @@ -13,9 +13,9 @@ #' are similar, which is the defining property of regular equivalence. #' They differ in how they pair up two nodes' alters. #' @template param_data -#' @param beta A decay parameter between 0 and 1 controlling how much weight -#' is given to the recursive component. By default 0.15. -#' @param iterations Integer number of iterations. +#' @template param_decay +#' @param beta Deprecated; use `decay` instead. +#' @param iterations Integer number of iterations. #' By default 3 for `regularity_rege()`; `regularity_rolesim()` iterates to convergence. #' @returns A square similarity matrix with one row and column per node. #' @references @@ -39,8 +39,9 @@ NULL #' RoleSim pairs up two nodes' alters by finding the _maximal matching_ #' between them, that is, the one-to-one pairing that maximises total #' similarity, and then averages over it: -#' \deqn{s(u,v) = (1-\beta) \frac{\sum_{(x,y) \in M} s(x,y)}{|N(u)| + |N(v)| - |M|} + \beta} -#' where \eqn{M} is that matching. +#' \deqn{s(u,v) = (1-\delta) \frac{\sum_{(x,y) \in M} s(x,y)}{|N(u)| + |N(v)| - |M|} + \delta} +#' where \eqn{M} is that matching and \eqn{\delta} is `decay`, +#' which RoleSim calls \eqn{\beta}; by default 0.15. #' Because each alter can be used only once, two nodes are similar only if #' their neighbourhoods can be lined up as wholes. #' @@ -49,16 +50,15 @@ NULL #' It converges to a unique solution regardless of where it starts, #' so the result does not depend on initialisation. #' @export -regularity_rolesim <- function(.data, beta = 0.15){ +regularity_rolesim <- function(.data, decay = 0.15, beta = NULL){ .data <- manynet::expect_nodes(.data) - if(beta < 0 | beta > 1) - manynet::snet_abort("`beta` must be a proportion between 0 and 1.") + decay <- check_decay(resolve_decay(decay, beta, "beta")) mat <- manynet::as_matrix(manynet::to_unweighted(manynet::to_multilevel(.data))) n <- nrow(mat) nbrs <- .neighbourhoods(mat, manynet::is_directed(.data)) sim <- matrix(1, n, n) # all nodes begin maximally similar for(it in seq_len(100L)){ - new <- .rolesim_step(sim, nbrs, beta, n) + new <- .rolesim_step(sim, nbrs, decay, n) if(max(abs(new - sim)) < 1e-6){ sim <- new; break } sim <- new } diff --git a/R/motif_brokerage.R b/R/motif_brokerage.R index fb89862..df4b538 100644 --- a/R/motif_brokerage.R +++ b/R/motif_brokerage.R @@ -139,7 +139,8 @@ node_by_brokering_activity <- function(.data, membership){ } # missings should be none out[is.na(out)] <- 0 - make_node_measure(out, .data) + make_node_measure(out, .data, measure = "brokerage activity", + range = c(0, Inf), normalization = "none") } #' @rdname measure_brokerage @@ -171,7 +172,8 @@ node_by_brokering_exclusivity <- function(.data, membership){ } # missings should be none out[is.na(out)] <- 0 - make_node_measure(out, .data) + make_node_measure(out, .data, measure = "brokerage exclusivity", + range = c(0, Inf), normalization = "none") } # Memberships #### diff --git a/man/measure_assort_net.Rd b/man/measure_assort_net.Rd index 66e308c..54593a9 100644 --- a/man/measure_assort_net.Rd +++ b/man/measure_assort_net.Rd @@ -64,6 +64,13 @@ a suitable alternative will be used instead with a message.} } \value{ A \code{network_measure} numeric score. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions offer ways to measure the distribution or assortativity @@ -98,6 +105,17 @@ This value can range from 1 to -1, where 1 indicates ties only between categories/groups and -1 ties only within categories/groups. } +\section{Spatial autocorrelation}{ + +Moran's I is conventionally read on \eqn{[-1, 1]}, where positive values +indicate that tied nodes hold similar values and negative values that they +hold dissimilar ones. Its actual bounds, however, are set by the +eigenvalues of the weight matrix, and on the unstandardised weights used +here it can fall outside that interval. Its range is therefore declared +open at both ends, and the conventional interval read as a guide rather +than a guarantee. +} + \examples{ marvel_friends <- to_unsigned(to_uniplex(fict_marvel, "relationship"), "positive") net_by_heterophily(marvel_friends, "Gender") diff --git a/man/measure_assort_node.Rd b/man/measure_assort_node.Rd index e59f485..e6d044f 100644 --- a/man/measure_assort_node.Rd +++ b/man/measure_assort_node.Rd @@ -61,6 +61,13 @@ A \code{node_measure} numeric vector the length of the nodes in the network, providing the scores for each node. If the network is labelled, then the scores will be labelled with the nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions offer ways to measure nodes' assortativity in a network: diff --git a/man/measure_breadth.Rd b/man/measure_breadth.Rd index d3a7b48..da67be5 100644 --- a/man/measure_breadth.Rd +++ b/man/measure_breadth.Rd @@ -17,6 +17,13 @@ For more information on possible coercions, see e.g. \code{\link[manynet:as_stoc } \value{ A \code{network_measure} numeric score. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions return values or vectors relating to how broad a network is. diff --git a/man/measure_broker_node.Rd b/man/measure_broker_node.Rd index 67a8928..c283d7b 100644 --- a/man/measure_broker_node.Rd +++ b/man/measure_broker_node.Rd @@ -35,6 +35,13 @@ A \code{node_measure} numeric vector the length of the nodes in the network, providing the scores for each node. If the network is labelled, then the scores will be labelled with the nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These function provide different measures of the degree to which nodes @@ -67,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{Constraint}{ + +Constraint has a natural floor at 0, for a node whose contacts are wholly +unconnected to one another, but no clean ceiling: the standard result is +that it can reach around 1.125 for one-mode networks, and the two-mode +form is a different summation again. Its declared range is therefore left +open above rather than asserting a bound the measure can exceed. +} + \examples{ node_by_bridges(ison_adolescents) node_by_bridges(ison_southern_women) diff --git a/man/measure_broker_tie.Rd b/man/measure_broker_tie.Rd index 37c3933..7b77509 100644 --- a/man/measure_broker_tie.Rd +++ b/man/measure_broker_tie.Rd @@ -17,11 +17,21 @@ A \code{tie_measure} numeric vector the length of the ties in the network, providing the scores for each tie. If the network is labelled, then the scores will be labelled with the ties' adjacent nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ \code{tie_by_cohesion()} measures the ratio between common neighbors to ties' adjacent nodes and the total number of adjacent nodes, where high values indicate ties' embeddedness in dense local environments. + +A tie whose two endpoints have no other neighbours has nothing to be +embedded in, and so returns \code{NaN} rather than 0. } \seealso{ Other brokerage: diff --git a/man/measure_brokerage.Rd b/man/measure_brokerage.Rd index f6ce506..7b0d1e5 100644 --- a/man/measure_brokerage.Rd +++ b/man/measure_brokerage.Rd @@ -27,6 +27,13 @@ A \code{node_measure} numeric vector the length of the nodes in the network, providing the scores for each node. If the network is labelled, then the scores will be labelled with the nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions include ways to measure nodes' brokerage activity and diff --git a/man/measure_central_between.Rd b/man/measure_central_between.Rd index b55176e..4056f62 100644 --- a/man/measure_central_between.Rd +++ b/man/measure_central_between.Rd @@ -34,6 +34,13 @@ A \code{node_measure} numeric vector the length of the nodes in the network, providing the scores for each node. If the network is labelled, then the scores will be labelled with the nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions calculate common betweenness-related centrality measures for one- and two-mode networks: @@ -71,13 +78,23 @@ measures where available, reported when the measure is printed. Betweenness centrality is based on the number of shortest paths between other nodes that a node lies upon: \deqn{C_B(i) = \sum_{j,k:j \neq k, j \neq i, k \neq i} \frac{g_{jik}}{g_{jk}}} + +Setting \code{cutoff} counts only those shortest paths no longer than \eqn{k}, +which elsewhere goes by \emph{distance-bounded betweenness} (Brandes, 2008) or +\emph{range-limited betweenness} (Ercsey-Ravasz et al., 2012). +Normalization still applies, so a bounded score remains comparable across +networks. } \section{Induced centrality}{ -Induced centrality or vitality centrality concerns the change in -total betweenness centrality between networks with and without a given node: +Induced centrality concerns the change in total betweenness centrality +between networks with and without a given node: \deqn{C_I(i) = C_B(G) - C_B(G\ i)} +This "remove the node and re-measure" logic is the general +\emph{delta centrality} framework of Latora and Marchiori (2007); +\code{node_by_induced()} is its betweenness instance, and +\code{\link[=node_by_vitality]{node_by_vitality()}} its closeness instance. } \section{Flow betweenness centrality}{ @@ -111,6 +128,19 @@ Freeman, Linton. 1977. \doi{10.2307/3033543} } +\subsection{On bounding path length}{ + +Brandes, Ulrik. 2008. +"On variants of shortest-path betweenness centrality and their generic computation". +\emph{Social Networks} 30(2): 136-145. +\doi{10.1016/j.socnet.2007.11.001} + +Ercsey-Ravasz, Maria, Ryan N. Lichtenwalter, Nitesh V. Chawla, and Zoltan Toroczkai. 2012. +"Range-limited centrality measures in complex networks". +\emph{Physical Review E} 85(6): 066103. +\doi{10.1103/PhysRevE.85.066103} +} + \subsection{On induced centrality}{ Everett, Martin and Steve Borgatti. 2010. @@ -119,11 +149,20 @@ Everett, Martin and Steve Borgatti. 2010. \doi{10.1016/j.socnet.2010.06.004} } +\subsection{On delta centrality}{ + +Latora, Vito, and Massimo Marchiori. 2007. +"A measure of centrality based on network efficiency". +\emph{New Journal of Physics} 9(6): 188. +\doi{10.1088/1367-2630/9/6/188} +} + \subsection{On flow centrality}{ -Freeman, Lin, Stephen Borgatti, and Douglas White. 1991. +Freeman, Linton C., Stephen P. Borgatti, and Douglas R. White. 1991. "Centrality in Valued Graphs: A Measure of Betweenness Based on Network Flow". \emph{Social Networks}, 13(2), 141-154. +\doi{10.1016/0378-8733(91)90017-N} Koschutzki, D., K.A. Lehmann, L. Peeters, S. Richter, D. Tenfelde-Podehl, and O. Zlotowski. 2005. "Centrality Indices". diff --git a/man/measure_central_close.Rd b/man/measure_central_close.Rd index 978d443..ee6133a 100644 --- a/man/measure_central_close.Rd +++ b/man/measure_central_close.Rd @@ -73,11 +73,13 @@ node's local neighbourhood. Where a measure is defined over all paths by default, a negative value or \code{NULL} imposes no limit.} -\item{decay}{A proportion between 0 and 1 indicating how quickly -the contribution of more distant nodes decays. -By default 0.5, so that each additional step halves a node's contribution. -As \code{decay} approaches 0 this approaches degree centrality, -and as it approaches 1 this approaches the size of the node's component.} +\item{decay}{A proportion between 0 and 1 giving how much of a contribution +survives each additional step of distance or walk length. +Lower values discount more steeply, so that only nearby others count; +higher values discount less, so that longer walks continue to contribute. +The measures that take a \code{decay} differ in what they discount and in what +value leaves the measure in its most familiar form, +so each documents its own default.} \item{from, to}{Index or name of a node to calculate distances from or to.} } @@ -86,6 +88,13 @@ A \code{node_measure} numeric vector the length of the nodes in the network, providing the scores for each node. If the network is labelled, then the scores will be labelled with the nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions calculate common closeness-related centrality measures @@ -132,8 +141,9 @@ are instead \emph{scaled} against the largest value observed in this network. } \section{Closeness centrality}{ -Closeness centrality, status centrality, or barycenter centrality is -defined as the reciprocal of the farness or distance, \eqn{d}, +Closeness centrality is also known as status centrality, +barycenter centrality, or the Sabidussi index. +It is defined as the reciprocal of the farness or distance, \eqn{d}, from a node to all other nodes in the network: \deqn{C_C(i) = \frac{1}{\sum_j d(i,j)}} When (more commonly) normalised, the numerator is instead \eqn{N-1}. @@ -173,10 +183,18 @@ other nodes in the network in \eqn{k} steps or less, but the normalised version, \eqn{\frac{C_R}{N-1}}, is more common. Note that if \eqn{k = 1} (i.e. cutoff = 1), then this returns the node's degree. At higher cutoff reach centrality returns the size of the node's component. +Counting the others reachable by a geodesic of length at most \eqn{k} is +also known as \emph{geodesic \eqn{k}-path centrality} (Borgatti and Everett, 2006); +note that it is not the same as the \eqn{k}-path indices that count paths +rather than nodes. } \section{Decay centrality}{ +Here \code{decay} defaults to 0.5, so that each additional step halves a node's +contribution. As it approaches 0 this approaches degree centrality, +and as it approaches 1 the size of the node's component. + Where reach centrality counts how many others are within a fixed number of steps, decay centrality weights every reachable other by how far away they are, so that nearer nodes count for more: @@ -226,6 +244,10 @@ and \eqn{T} is the trace of \eqn{C} and \eqn{S_R} an arbitrary row sum Nodes with higher information centrality have a large number of short paths to many others in the network, and are thus considered to have greater control of the flow of information. + +Information centrality is the closeness-like member of the current-flow +family; its betweenness-like counterpart is random walk (or current-flow) +betweenness centrality, which netrics does not yet offer. } \section{Eccentricity centrality}{ @@ -263,6 +285,8 @@ Formally: \deqn{C_V(i) = \sum_{j,k} d(j,k) - \sum_{j,k} d(j,k,G\ i)} where \eqn{d(j,k,G\ i)} is the distance between nodes \eqn{j} and \eqn{k} in the network with node \eqn{i} removed. +This is the closeness instance of the \emph{delta centrality} framework; +for its betweenness instance see \code{\link[=node_by_induced]{node_by_induced()}}. } \section{Random walk closeness centrality}{ @@ -287,6 +311,11 @@ node_by_radiality(ison_adolescents) \references{ \subsection{On closeness centrality}{ +Sabidussi, Gert. 1966. +"The centrality index of a graph". +\emph{Psychometrika}, 31(4): 581–603. +\doi{10.1007/BF02289527} + Bavelas, Alex. 1950. "Communication Patterns in Task‐Oriented Groups". \emph{The Journal of the Acoustical Society of America}, 22(6): 725–730. @@ -308,6 +337,11 @@ Marchiori, Massimo, and Vito Latora. 2000. Dekker, Anthony. 2005. "Conceptual distance in social network analysis". \emph{Journal of Social Structure} 6(3). + +Boldi, Paolo, and Sebastiano Vigna. 2014. +"Axioms for Centrality". +\emph{Internet Mathematics} 10(3-4): 222-262. +\doi{10.1080/15427951.2013.865686} } \subsection{On reach centrality}{ @@ -315,6 +349,11 @@ Dekker, Anthony. 2005. Borgatti, Stephen P., Martin G. Everett, and J.C. Johnson. 2013. \emph{Analyzing social networks}. London: SAGE Publications Limited. + +Borgatti, Stephen P., and Martin G. Everett. 2006. +"A graph-theoretic perspective on centrality". +\emph{Social Networks} 28(4): 466-484. +\doi{10.1016/j.socnet.2005.11.005} } \subsection{On decay centrality}{ diff --git a/man/measure_central_degree.Rd b/man/measure_central_degree.Rd index e122f5d..a1f30eb 100644 --- a/man/measure_central_degree.Rd +++ b/man/measure_central_degree.Rd @@ -63,6 +63,13 @@ A \code{node_measure} numeric vector the length of the nodes in the network, providing the scores for each node. If the network is labelled, then the scores will be labelled with the nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions calculate common degree-related centrality measures for one- and two-mode networks: @@ -108,6 +115,15 @@ outdegree (degree of outgoing ties) and indegree (degree of incoming ties). } +\section{Strength centrality}{ + +Given a weighted network, \code{node_by_degree()} sums tie weights rather than +counting ties, which is also known as \emph{strength centrality} or \emph{weighted +degree centrality}. The \code{alpha} argument tunes between the two, following +Opsahl et al. (2010), and the measure reports itself as +"strength centrality" whenever \code{alpha} is not zero. +} + \section{Leverage centrality}{ Leverage centrality concerns the degree of a node compared with that of its @@ -119,6 +135,14 @@ neighbours, \eqn{J}: node_by_degree(ison_southern_women) } \references{ +\subsection{On degree centrality}{ + +Freeman, Linton C. 1978. +"Centrality in social networks: Conceptual clarification". +\emph{Social Networks} 1(3): 215-239. +\doi{10.1016/0378-8733(78)90021-7} +} + \subsection{On multimodal centrality}{ Faust, Katherine. 1997. diff --git a/man/measure_central_eigen.Rd b/man/measure_central_eigen.Rd index 2566714..fb50c38 100644 --- a/man/measure_central_eigen.Rd +++ b/man/measure_central_eigen.Rd @@ -22,15 +22,15 @@ node_by_power( exponent = 1 ) -node_by_alpha(.data, alpha = 0.85) +node_by_alpha(.data, decay = 0.85, alpha = NULL) -node_by_pagerank(.data) +node_by_pagerank(.data, decay = 0.85) node_by_authority(.data, scaled = TRUE) node_by_hub(.data, scaled = TRUE) -node_by_subgraph(.data) +node_by_subgraph(.data, decay = 1, method = c("all", "odd", "even")) node_by_posneg(.data) } @@ -55,16 +55,36 @@ scores are not comparable across different networks.} the Bonacich power centrality score. Can be positive or negative.} -\item{alpha}{A constant that trades off the importance of external influence against the importance of connection. -When \eqn{\alpha = 0}, only the external influence matters. -As \eqn{\alpha} gets larger, only the connectivity matters and we reduce to eigenvector centrality. -By default \eqn{\alpha = 0.85}.} +\item{decay}{A proportion between 0 and 1 giving how much of a contribution +survives each additional step of distance or walk length. +Lower values discount more steeply, so that only nearby others count; +higher values discount less, so that longer walks continue to contribute. +The measures that take a \code{decay} differ in what they discount and in what +value leaves the measure in its most familiar form, +so each documents its own default.} + +\item{alpha}{Deprecated; use \code{decay} instead.} + +\item{method}{Character string indicating which closed walks to count. +By default \code{"all"}, which is subgraph centrality as usually defined. +\code{"odd"} counts only walks of odd length and \code{"even"} only those of even +length; the two sum to \code{"all"}. +Odd closed walks cannot occur within a bipartite structure, so a node +scoring near zero on \code{"odd"} sits in a locally two-mode-like neighbourhood. +See \code{\link[=net_by_bipartivity]{net_by_bipartivity()}} for the network-level counterpart.} } \value{ A \code{node_measure} numeric vector the length of the nodes in the network, providing the scores for each node. If the network is labelled, then the scores will be labelled with the nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions calculate common eigenvector-related centrality @@ -138,7 +158,16 @@ the network size. \section{Alpha centrality}{ -Alpha or Katz (or Katz-Bonacich) centrality operates better than +Alpha centrality is also known as Katz centrality, Katz-Bonacich +centrality, or Katz status. +The measure is named for the \eqn{\alpha} of Bonacich and Lloyd, which +trades off the importance of external influence against the importance of +connection: when \eqn{\alpha = 0} only the external influence matters, and +as \eqn{\alpha} grows only the connectivity matters and we reduce to +eigenvector centrality. +Since \eqn{\alpha} is a per-step discount, netrics takes it as \code{decay}, +the name it uses for that parameter throughout; by default 0.85. +It operates better than eigenvector centrality for directed networks because eigenvector centrality will return 0s for all nodes not in the main strongly-connected component. Each node's alpha centrality can be defined as: @@ -158,17 +187,41 @@ Rather than performing this iteration though, most routines solve the equation \eqn{x = (I - \frac{1}{\lambda} A^T)^{-1} e}. } +\section{Pagerank centrality}{ + +Pagerank centrality, or the PageRank citation ranking, is the stationary +distribution of a random walk that at each step either follows an outgoing +tie or teleports to a node chosen at random. +Scores are therefore already shares that sum to one. +\code{decay} is the probability of following a tie rather than teleporting, +elsewhere called the damping factor; by default 0.85. +As it approaches 0 the walk teleports at every step and all nodes score +alike; as it approaches 1 the walk never teleports. +} + +\section{Hub and authority centrality}{ + +Hub and authority centrality are the two halves of Kleinberg's HITS +(Hyperlink-Induced Topic Search) algorithm, and are computed together: +good authorities are pointed to by good hubs, and good hubs point to good +authorities. \code{node_by_hub()} and \code{node_by_authority()} return one each. +In an undirected network the two coincide. +} + \section{Subgraph centrality}{ Subgraph centrality measures the participation of a node in all subgraphs in the network, giving higher weight to smaller subgraphs. It is defined as: -\deqn{C_S(i) = \sum_{k=0}^{\infty} \frac{(A^k)_{ii}}{k!}} +\deqn{C_S(i) = \sum_{k=0}^{\infty} \frac{\delta^k (A^k)_{ii}}{k!}} where \eqn{(A^k)_{ii}} is the \eqn{i}th diagonal element of the \eqn{k}th power of the adjacency matrix \eqn{A}, representing the number of closed walks of length \eqn{k} starting and ending at node \eqn{i}. Weighting by \eqn{\frac{1}{k!}} ensures that shorter walks contribute more to the centrality score than longer walks. +The \code{decay} parameter \eqn{\delta} tunes that further, discounting each +step by a further factor: at the default of 1 the measure takes its usual +form, and lower values concentrate it on ever shorter walks. Subgraph centrality is a good choice of measure when the focus is on local connectivity and clustering around a node, @@ -176,6 +229,9 @@ as it captures the extent to which a node is embedded in tightly-knit groups within the network. Note though that because of the way spectral decomposition is used to calculate this measure, this is not a good measure for very large graphs. + +Summing these scores over all nodes gives the network's \emph{Estrada index}, +so a node's subgraph centrality is its contribution to that index. } \section{PN (positive-negative) centrality}{ @@ -197,6 +253,11 @@ node_by_power(ison_southern_women, exponent = 0.5) \references{ \subsection{On eigenvector centrality}{ +Bonacich, Phillip. 1972. +“Factoring and Weighting Approaches to Status Scores and Clique Identification.” +\emph{The Journal of Mathematical Sociology} 2(1): 113–120. +\doi{10.1080/0022250X.1972.9989806} + Bonacich, Phillip. 1991. “Simultaneous Group and Individual Centralities.” \emph{Social Networks} 13(2):155–68. @@ -227,6 +288,10 @@ Bonacich, P. and Lloyd, P. 2001. Brin, Sergey and Page, Larry. 1998. "The anatomy of a large-scale hypertextual web search engine". \emph{Proceedings of the 7th World-Wide Web Conference}. Brisbane, Australia. + +Page, Lawrence, Sergey Brin, Rajeev Motwani, and Terry Winograd. 1999. +"The PageRank Citation Ranking: Bringing Order to the Web". +\emph{Stanford InfoLab Technical Report} 1999-66. } \subsection{On hub and authority centrality}{ @@ -245,6 +310,14 @@ Estrada, Ernesto and Rodríguez-Velázquez, Juan A. 2005. \doi{10.1103/PhysRevE.71.056103} } +\subsection{On odd and even closed walks}{ + +Estrada, Ernesto and Rodríguez-Velázquez, Juan A. 2005. +"Spectral measures of bipartivity in complex networks". +\emph{Physical Review E} 72(4): 046105. +\doi{10.1103/PhysRevE.72.046105} +} + \subsection{On signed centrality}{ Everett, Martin G., and Stephen P. Borgatti. 2014. diff --git a/man/measure_centralisation_close.Rd b/man/measure_centralisation_close.Rd index ecdc4f1..c464fba 100644 --- a/man/measure_centralisation_close.Rd +++ b/man/measure_centralisation_close.Rd @@ -42,11 +42,13 @@ node's local neighbourhood. Where a measure is defined over all paths by default, a negative value or \code{NULL} imposes no limit.} -\item{decay}{A proportion between 0 and 1 indicating how quickly -the contribution of more distant nodes decays. -By default 0.5, so that each additional step halves a node's contribution. -As \code{decay} approaches 0 this approaches degree centrality, -and as it approaches 1 this approaches the size of the node's component.} +\item{decay}{A proportion between 0 and 1 giving how much of a contribution +survives each additional step of distance or walk length. +Lower values discount more steeply, so that only nearby others count; +higher values discount less, so that longer walks continue to contribute. +The measures that take a \code{decay} differ in what they discount and in what +value leaves the measure in its most familiar form, +so each documents its own default.} } \value{ \verb{net_by_*()} functions return a \code{network_measure} scalar; diff --git a/man/measure_centralisation_degree.Rd b/man/measure_centralisation_degree.Rd index c5c81ff..69fe18b 100644 --- a/man/measure_centralisation_degree.Rd +++ b/man/measure_centralisation_degree.Rd @@ -81,11 +81,22 @@ net_by_degree(ison_southern_women, direction = "in") mode_by_degree(ison_southern_women, direction = "in") } \references{ +\subsection{On centralisation}{ + +Freeman, Linton C. 1978. +"Centrality in social networks: Conceptual clarification". +\emph{Social Networks} 1(3): 215-239. +\doi{10.1016/0378-8733(78)90021-7} +} + +\subsection{On two-mode centralisation}{ + Borgatti, Stephen P., and Martin G. Everett. 1997. "Network analysis of 2-mode data." \emph{Social Networks} 19(3): 243-269. \doi{10.1016/S0378-8733(96)00301-2} } +} \seealso{ Other degree: \code{\link{mark_degree}}, diff --git a/man/measure_centralities_between.Rd b/man/measure_centralities_between.Rd index 7181569..9a6783f 100644 --- a/man/measure_centralities_between.Rd +++ b/man/measure_centralities_between.Rd @@ -22,6 +22,13 @@ A \code{tie_measure} numeric vector the length of the ties in the network, providing the scores for each tie. If the network is labelled, then the scores will be labelled with the ties' adjacent nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ \code{tie_by_betweenness()} measures the number of shortest paths going through a tie. @@ -33,10 +40,33 @@ 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{Edge betweenness centrality}{ + +The betweenness centrality of a tie, also known as \emph{edge betweenness}, +counts the shortest paths between other nodes that run along it. +It is best known as the quantity iteratively recomputed by the +Girvan-Newman community detection algorithm, where the ties with the +highest betweenness are removed first; see \code{\link[=node_in_betweenness]{node_in_betweenness()}}. +} + \examples{ (tb <- tie_by_betweenness(ison_adolescents)) ison_adolescents |> mutate_ties(weight = tb) } +\references{ +\subsection{On edge betweenness centrality}{ + +Girvan, Michelle, and Mark E.J. Newman. 2002. +"Community structure in social and biological networks". +\emph{Proceedings of the National Academy of Sciences} 99(12): 7821-7826. +\doi{10.1073/pnas.122653799} + +Brandes, Ulrik. 2001. +"A faster algorithm for betweenness centrality". +\emph{Journal of Mathematical Sociology} 25(2): 163-177. +\doi{10.1080/0022250X.2001.9990249} +} +} \seealso{ Other betweenness: \code{\link{measure_central_between}}, diff --git a/man/measure_centralities_close.Rd b/man/measure_centralities_close.Rd index 8af0996..77cbcc8 100644 --- a/man/measure_centralities_close.Rd +++ b/man/measure_centralities_close.Rd @@ -22,6 +22,13 @@ A \code{tie_measure} numeric vector the length of the ties in the network, providing the scores for each tie. If the network is labelled, then the scores will be labelled with the ties' adjacent nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ \code{tie_by_closeness()} measures the closeness of each tie to other ties diff --git a/man/measure_centralities_degree.Rd b/man/measure_centralities_degree.Rd index 1b287e7..b783201 100644 --- a/man/measure_centralities_degree.Rd +++ b/man/measure_centralities_degree.Rd @@ -22,6 +22,13 @@ A \code{tie_measure} numeric vector the length of the ties in the network, providing the scores for each tie. If the network is labelled, then the scores will be labelled with the ties' adjacent nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ \code{tie_by_degree()} measures the degree centrality of ties in a network diff --git a/man/measure_centralities_eigen.Rd b/man/measure_centralities_eigen.Rd index f8c547f..689f0fa 100644 --- a/man/measure_centralities_eigen.Rd +++ b/man/measure_centralities_eigen.Rd @@ -22,6 +22,13 @@ A \code{tie_measure} numeric vector the length of the ties in the network, providing the scores for each tie. If the network is labelled, then the scores will be labelled with the ties' adjacent nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ \code{tie_by_eigenvector()} measures the eigenvector centrality of ties in a diff --git a/man/measure_closure.Rd b/man/measure_closure.Rd index bf168fb..aea0af7 100644 --- a/man/measure_closure.Rd +++ b/man/measure_closure.Rd @@ -9,7 +9,7 @@ \alias{net_by_congruency} \title{Measuring network closure} \usage{ -net_by_reciprocity(.data, method = "default") +net_by_reciprocity(.data, method = c("default", "ratio")) net_by_transitivity(.data) @@ -31,6 +31,13 @@ See \code{?igraph::reciprocity}} } \value{ A \code{network_measure} numeric score. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions offer methods for summarising the closure in configurations @@ -74,7 +81,8 @@ closed in the other, so cyclicality and transitivity coincide. The \code{net_by_equivalency()} function calculates the Robins and Alexander (2004) clustering coefficient for two-mode networks. -Note that for weighted two-mode networks, the result is divided by the average tie weight. +The coefficient is a proportion of three-paths, and so is defined on +binary data; weighted networks are dichotomised before it is calculated. } \examples{ diff --git a/man/measure_closure_node.Rd b/man/measure_closure_node.Rd index 0659dee..8b86d60 100644 --- a/man/measure_closure_node.Rd +++ b/man/measure_closure_node.Rd @@ -23,6 +23,13 @@ A \code{node_measure} numeric vector the length of the nodes in the network, providing the scores for each node. If the network is labelled, then the scores will be labelled with the nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions offer methods for summarising the closure in configurations @@ -41,10 +48,31 @@ For one-mode networks, shallow wrappers of igraph versions exist via For two-mode networks, \code{node_by_equivalency} calculates the proportion of three-paths in the network that are closed by fourth tie to establish a "shared four-cycle" structure. } +\section{Node transitivity}{ + +A node's transitivity is the proportion of its neighbours that are +themselves connected, which is also known as the \emph{local clustering +coefficient} of the node. +} + \examples{ -node_by_reciprocity(to_unweighted(ison_networkers)) +node_by_reciprocity(ison_networkers) node_by_transitivity(ison_adolescents) } +\references{ +\subsection{On the local clustering coefficient}{ + +Watts, Duncan J., and Steven H. Strogatz. 1998. +"Collective dynamics of 'small-world' networks". +\emph{Nature} 393(6684): 440-442. +\doi{10.1038/30918} + +Holland, Paul W., and Samuel Leinhardt. 1971. +"Transitivity in structural models of small groups". +\emph{Comparative Group Studies} 2(2): 107-124. +\doi{10.1177/104649647100200201} +} +} \seealso{ Other measures: \code{\link{measure_assort_net}}, diff --git a/man/measure_cohesion.Rd b/man/measure_cohesion.Rd index 8153355..cd5b47c 100644 --- a/man/measure_cohesion.Rd +++ b/man/measure_cohesion.Rd @@ -23,6 +23,13 @@ For more information on possible coercions, see e.g. \code{\link[manynet:as_stoc } \value{ A \code{network_measure} numeric score. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions return values or vectors relating to how cohesive a network is: diff --git a/man/measure_core.Rd b/man/measure_core.Rd index 96932b4..92c8383 100644 --- a/man/measure_core.Rd +++ b/man/measure_core.Rd @@ -20,6 +20,13 @@ A \code{node_measure} numeric vector the length of the nodes in the network, providing the scores for each node. If the network is labelled, then the scores will be labelled with the nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions identify nodes belonging to (some level of) the core of a network: diff --git a/man/measure_diffusion_infection.Rd b/man/measure_diffusion_infection.Rd index 435271e..01a83f5 100644 --- a/man/measure_diffusion_infection.Rd +++ b/man/measure_diffusion_infection.Rd @@ -26,6 +26,13 @@ By default TRUE.} } \value{ A \code{network_measure} numeric score. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions allow measurement of various features of diff --git a/man/measure_diffusion_net.Rd b/man/measure_diffusion_net.Rd index fb7ec82..2cc2331 100644 --- a/man/measure_diffusion_net.Rd +++ b/man/measure_diffusion_net.Rd @@ -34,6 +34,13 @@ By default TRUE.} } \value{ A \code{network_measure} numeric score. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions allow measurement of various features of @@ -120,6 +127,11 @@ A HIT or immunity score of 0.75 would mean that 75\% of the nodes in the network would need to be vaccinated or otherwise protected to achieve herd immunity. To identify how many nodes this would be, multiply this proportion with the number of nodes in the network. + +Where \eqn{R < 1} the diffusion is already sub-critical and dies out of its +own accord, so no one needs protecting and the threshold is reported as 0. +The formula would otherwise return a negative proportion, which has no +interpretation. } \examples{ diff --git a/man/measure_diffusion_node.Rd b/man/measure_diffusion_node.Rd index f32cf79..fb0aa89 100644 --- a/man/measure_diffusion_node.Rd +++ b/man/measure_diffusion_node.Rd @@ -41,6 +41,13 @@ A \code{node_measure} numeric vector the length of the nodes in the network, providing the scores for each node. If the network is labelled, then the scores will be labelled with the nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions allow measurement of various features of diff --git a/man/measure_diverse_net.Rd b/man/measure_diverse_net.Rd index c7d38f6..f7987bc 100644 --- a/man/measure_diverse_net.Rd +++ b/man/measure_diverse_net.Rd @@ -30,6 +30,13 @@ a suitable alternative will be used instead with a message.} } \value{ A \code{network_measure} numeric score. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions offer ways to measure the heterogeneity of an attribute diff --git a/man/measure_diverse_node.Rd b/man/measure_diverse_node.Rd index bfae8b8..6fc229a 100644 --- a/man/measure_diverse_node.Rd +++ b/man/measure_diverse_node.Rd @@ -33,6 +33,13 @@ A \code{node_measure} numeric vector the length of the nodes in the network, providing the scores for each node. If the network is labelled, then the scores will be labelled with the nodes' names. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions offer ways to measure the heterogeneity of an attribute diff --git a/man/measure_features.Rd b/man/measure_features.Rd index a36b021..d4a8e6a 100644 --- a/man/measure_features.Rd +++ b/man/measure_features.Rd @@ -5,6 +5,7 @@ \alias{net_by_richclub} \alias{net_by_smallworld} \alias{net_by_scalefree} +\alias{net_by_bipartivity} \alias{net_by_balance} \title{Measuring network topological features} \source{ @@ -17,6 +18,8 @@ net_by_smallworld(.data, method = c("omega", "sigma", "SWI"), times = 100) net_by_scalefree(.data) +net_by_bipartivity(.data) + net_by_balance(.data) } \arguments{ @@ -38,20 +41,28 @@ but this measure is highly sensitive to network size. \deqn{\frac{L_r}{L} - \frac{C}{C_l}}, where \eqn{C_l} is the clustering coefficient for a lattice graph with the same dimensions. -\eqn{\omega} ranges between 0 and 1, -where 1 is as close to a small-world as possible. +\eqn{\omega} ranges between -1 and 1, where values close to 0 are +as close to a small-world as possible; negative values indicate a +lattice-like network, and positive values a more random one. \item "SWI" is an alternative proposed by Neal (2017), \deqn{\frac{L - L_l}{L_r - L_l} \times \frac{C - C_r}{C_l - C_r}}, where \eqn{L_l} is the average path length for a lattice graph with the same dimensions. -\eqn{SWI} also ranges between 0 and 1 with the same interpretation, -but where there may not be a network for which \eqn{SWI = 1}. +\eqn{SWI} ranges between 0 and 1, where 1 is as close to a small-world +as possible, though there may not be a network for which \eqn{SWI = 1}. }} \item{times}{Integer of number of simulations.} } \value{ A \code{network_measure} numeric score. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions measure topological features that are intrinsic to a @@ -69,8 +80,29 @@ a power-law distribution. the proportion of balanced triangles, ranging between \code{0} if all triangles are imbalanced and \code{1} if all triangles are balanced. +\item \code{net_by_bipartivity()} measures how close a network is to being +bipartite, that is, to dividing into two sets with ties only between them. } } +\section{Bipartivity}{ + +A network is bipartite when its nodes divide into two sets with ties only +running between them and never within, which is exactly the condition that +it contains no closed walk of odd length. +Bipartivity therefore measures how close a network comes to that condition, +as the share of its closed walks that are of even length: +\deqn{b(G) = \frac{\sum_i C_{even}(i)}{\sum_i C_{all}(i)}} +A genuinely two-mode network scores exactly 1, +and the more odd-length structure a network carries — triangles above all — +the further it falls below 1. +Note that this asks whether a network \emph{could} be split in two, +not whether it has been: it is defined on a one-mode network, +whereas \code{\link[manynet:is_twomode]{manynet::is_twomode()}} reports whether nodes are already +partitioned into two modes. +The node-level counterpart is \code{\link[=node_by_subgraph]{node_by_subgraph()}} with +\code{method = "odd"} or \code{"even"}. +} + \examples{ net_by_richclub(ison_adolescents) net_by_smallworld(ison_brandes) @@ -78,6 +110,9 @@ net_by_smallworld(ison_southern_women) net_by_scalefree(ison_adolescents) net_by_scalefree(generate_scalefree(50, 1.5)) net_by_scalefree(create_lattice(100)) +# A two-mode network is bipartite by construction +net_by_bipartivity(ison_southern_women) +net_by_bipartivity(ison_adolescents) net_by_balance(to_uniplex(fict_marvel, "relationship")) } \references{ @@ -130,6 +165,14 @@ Holme, Petter. 2019. \doi{10.1038/s41467-019-09038-8} } +\subsection{On bipartivity}{ + +Estrada, Ernesto, and Juan A. Rodríguez-Velázquez. 2005. +"Spectral measures of bipartivity in complex networks". +\emph{Physical Review E} 72(4): 046105. +\doi{10.1103/PhysRevE.72.046105} +} + \subsection{On balance theory}{ Heider, Fritz. 1946. diff --git a/man/measure_fit.Rd b/man/measure_fit.Rd index 8a289b5..041c53b 100644 --- a/man/measure_fit.Rd +++ b/man/measure_fit.Rd @@ -57,6 +57,13 @@ See the section below.} } \value{ A \code{network_measure} numeric score. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions measure how well some proposed structure describes a @@ -84,7 +91,7 @@ direction, so they are not interchangeable:\tabular{llll}{ measure \tab compares the network against \tab range \tab better \cr \code{net_by_core()} \tab a core-periphery model \tab -1 to 1 \tab higher \cr \code{net_by_factions()} \tab a components model \tab -1 to 1 \tab higher \cr - \code{net_by_modularity()} \tab the partition's communities \tab -0.5 to 1 \tab higher \cr + \code{net_by_modularity()} \tab the partition's communities \tab -0.5 to 1 (at the default resolution) \tab higher \cr \code{net_by_inconsistency()} \tab ideal block types \tab 0 upwards \tab \strong{lower} \cr } @@ -103,9 +110,11 @@ and the periphery. Modularity measures the difference between the number of ties within each community from the number of ties expected within each community in a random graph -with the same degrees, and ranges between -1 and +1. -Modularity scores of +1 mean that ties only appear within communities, -while -1 would mean that ties only appear between communities. +with the same degrees. At the default \code{resolution} it ranges between +-0.5 and +1; a higher resolution can push it further below that floor. +Modularity scores approaching +1 mean that ties only appear within +communities, while negative scores mean that ties appear between +communities more often than chance would predict. A score of 0 would mean that ties are half within and half between communities, as one would expect in a random graph. diff --git a/man/measure_fragmentation.Rd b/man/measure_fragmentation.Rd index 4318f69..fea5ade 100644 --- a/man/measure_fragmentation.Rd +++ b/man/measure_fragmentation.Rd @@ -23,6 +23,13 @@ For more information on possible coercions, see e.g. \code{\link[manynet:as_stoc } \value{ A \code{network_measure} numeric score. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions return values relating to how connected a network is diff --git a/man/measure_hierarchy.Rd b/man/measure_hierarchy.Rd index de7f2a0..fe44790 100644 --- a/man/measure_hierarchy.Rd +++ b/man/measure_hierarchy.Rd @@ -20,6 +20,13 @@ For more information on possible coercions, see e.g. \code{\link[manynet:as_stoc } \value{ A \code{network_measure} numeric score. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ These functions, together with \code{net_reciprocity()}, are used jointly to @@ -32,6 +39,19 @@ or the degree to which network is a single component. \item \code{net_by_upperbound()} measures the Krackhardt (least) upper bound score. } } +\section{Efficiency}{ + +A perfect hierarchy is a tree: every node but the root has exactly one +superior, and there are no ties to spare. Krackhardt's efficiency asks how +close a network comes to that, by counting the ties it carries in excess of +the minimum needed to hold its components together, as a proportion of the +most excess ties it could possibly carry: +\deqn{E = 1 - \frac{|E| - \sum_i (N_i - 1)}{\sum_i \left(M_i - (N_i - 1)\right)}} +where \eqn{N_i} is the size of weak component \eqn{i} and \eqn{M_i} the +number of ties possible within it. A tree or forest scores 1, and a +complete network 0. +} + \examples{ net_by_connectedness(ison_networkers) 1 - net_by_reciprocity(ison_networkers) diff --git a/man/measure_periods.Rd b/man/measure_periods.Rd index 20ba664..1d6d4ad 100644 --- a/man/measure_periods.Rd +++ b/man/measure_periods.Rd @@ -14,6 +14,13 @@ For more information on possible coercions, see e.g. \code{\link[manynet:as_stoc } \value{ A \code{network_measure} numeric score. + +The object also carries the \code{measure} it computed, the \code{range} its values +can fall within, and whether and how those values were \code{normalized}. +These are shown as a one-line header when the object is printed. +Where a measure offers a choice between several ways of counting the +same thing, it also carries the \code{variant} it used. +All can be retrieved with \code{attr()}. } \description{ \code{net_by_waves()} measures the number of waves in longitudinal network data. diff --git a/tests/testthat/helper-contract.R b/tests/testthat/helper-contract.R new file mode 100644 index 0000000..f33ba0e --- /dev/null +++ b/tests/testthat/helper-contract.R @@ -0,0 +1,385 @@ +# The family-wide contract for measures. +# +# Rather than adding a test per function, each family sweeps its whole roster +# and checks the promises the documentation makes: that a measure returns the +# right shape, that it declares what it computed, that it stays inside the +# range it declares, that the normalisation it declares is the one it +# performed, and that its arguments actually do something. +# +# Where a function does not (yet) meet the contract, the sweep records an +# audit message rather than failing, so that the outstanding gaps are +# enumerated on every run instead of being either invisible or a red build. +# The list of audit messages is the remaining work; the aim is for it to +# shrink to empty. `report_contract_gaps()` prints it. + +# The rosters live here rather than in the family test files so that the +# registry check below is complete however few test files are run. Each roster +# maps a function name to any arguments needed to make it applicable; the +# fixture it is run against belongs with the test that runs it. +measure_rosters <- list( + + centrality_node = list( + node_by_degree = list(), + node_by_deg = list(), + node_by_indegree = list(), + node_by_outdegree = list(), + node_by_leverage = list(), + node_by_closeness = list(), + node_by_harmonic = list(), + node_by_reach = list(), + node_by_decay = list(), + node_by_integration = list(), + node_by_radiality = list(), + node_by_eccentricity = list(), + node_by_vitality = list(), + node_by_randomwalk = list(), + node_by_betweenness = list(), + node_by_induced = list(), + node_by_flow = list(), + node_by_stress = list(), + node_by_information = list(), + node_by_eigenvector = list(), + node_by_power = list(), + node_by_alpha = list(), + node_by_pagerank = list(), + node_by_hub = list(), + node_by_authority = list(), + node_by_subgraph = list(), + node_by_distance = list(from = 1) + ), + # PN centrality is defined on signed data, so it runs against its own fixture. + centrality_signed = list( + node_by_posneg = list() + ), + # Multidegree needs multiplex data, so it runs against its own fixture. + centrality_multiplex = list( + node_by_multidegree = list(tie1 = "relationship", tie2 = "affiliation") + ), + centrality_tie = list( + tie_by_degree = list(), + tie_by_closeness = list(), + tie_by_betweenness = list(), + tie_by_eigenvector = list() + ), + centrality_net = list( + net_by_degree = list(), + net_by_indegree = list(), + net_by_outdegree = list(), + net_by_closeness = list(), + net_by_betweenness = list(), + net_by_eigenvector = list(), + net_by_reach = list(), + net_by_decay = list(), + net_by_integration = list(), + net_by_harmonic = list() + ), + centrality_mode = list( + mode_by_degree = list(), + mode_by_indegree = list(), + mode_by_outdegree = list(), + mode_by_closeness = list(), + mode_by_betweenness = list(), + mode_by_eigenvector = list() + ), + + closure_net = list( + net_by_reciprocity = list(), + net_by_transitivity = list(), + net_by_cyclicality = list(), + net_by_equivalency = list() + ), + closure_node = list( + node_by_reciprocity = list(), + node_by_transitivity = list(), + node_by_equivalency = list() + ), + + cohesion_net = list( + net_by_density = list(), + net_by_compactness = list(), + net_by_components = list(), + net_by_independence = list(), + net_by_diameter = list(), + net_by_length = list(), + net_by_cohesion = list(), + net_by_adhesion = list() + ), + # Strength and toughness enumerate every subset of ties or nodes, so they get + # a small fixture of their own. + fragmentation_net = list( + net_by_strength = list(), + net_by_toughness = list() + ), + + diffusion_net = list( + net_by_transmissibility = list(), + net_by_recovery = list(), + net_by_reproduction = list(), + net_by_immunity = list(), + net_by_infection_complete = list(), + net_by_infection_total = list(), + net_by_infection_peak = list() + ), + diffusion_node = list( + node_by_adopt_time = list(), + node_by_adopt_threshold = list(), + node_by_adopt_recovery = list() + ), + diffusion_exposure = list( + node_by_adopt_exposure = list(mark = c(1, 3)) + ), + + features_net = list( + net_by_richclub = list(), + net_by_scalefree = list(), + net_by_bipartivity = list(), + net_by_smallworld = list(times = 20) + ), + features_balance = list( + net_by_balance = list() + ), + fit_net = list( + net_by_core = list(), + net_by_factions = list(), + net_by_modularity = list(), + net_by_inconsistency = list() + ), + + heterogeneity_net = list( + net_by_richness = list(attribute = "Gender"), + net_by_diversity = list(attribute = "Gender"), + net_by_heterophily = list(attribute = "Gender"), + net_by_homophily = list(attribute = "Gender"), + net_by_assortativity = list() + ), + heterogeneity_node = list( + node_by_richness = list(attribute = "Gender"), + node_by_diversity = list(attribute = "Gender"), + node_by_heterophily = list(attribute = "Gender"), + node_by_homophily = list(attribute = "Gender") + ), + heterogeneity_spatial = list( + net_by_spatial = list(attribute = "age") + ), + + holes_node = list( + node_by_bridges = list(), + node_by_redundancy = list(), + node_by_effsize = list(), + node_by_efficiency = list(), + node_by_constraint = list(), + node_by_hierarchy = list(), + node_by_neighbours_degree = list() + ), + holes_tie = list( + tie_by_cohesion = list() + ), + + hierarchy_net = list( + net_by_connectedness = list(), + net_by_efficiency = list(), + net_by_upperbound = list() + ), + core_node = list( + node_by_kcoreness = list(), + node_by_coreness = list() + ), + brokerage_node = list( + node_by_brokering_activity = list(membership = "Discipline"), + node_by_brokering_exclusivity = list(membership = "Discipline") + ), + change_net = list( + net_by_waves = list() + ) +) + +# Measures the sweep does not reach through a single-fixture roster, each with +# the reason it is exempt rather than merely absent. +uncontracted_measures <- c( + # Takes two two-mode networks rather than one network, so it does not fit + # the roster shape; covered by its own test in the closure contract file. + "net_by_congruency" +) + +audit <- new.env(parent = emptyenv()) +audit$notes <- character() +audit$covered <- character() + +note_gap <- function(fn, gap) { + audit$notes <- c(audit$notes, paste0(fn, ": ", gap)) + invisible(NULL) +} + +call_measure <- function(fn, args, .data) { + do.call(fn, c(list(.data), args)) +} + +# Documented exemptions from the "arguments are live" contract. These are +# deliberate declarations, not gaps: an eigenvector is defined only up to a +# scalar multiple, so its scores carry no absolute units to preserve and +# scaling is intrinsic rather than optional. +inert_arguments <- list( + node_by_eigenvector = c("normalized", "scaled") +) + +# Measures that declare a normalisation but not the bound that usually comes +# with it. `net_by_infection_total()` divides by the number of nodes, but a +# node can be infected more than once where reinfection is possible, so the +# proportion is genuinely not capped at 1. +unbounded_normalization <- c("net_by_infection_total") + +# The contract sweep. `roster` is a named list mapping function names to any +# arguments needed to make them applicable; `.data` is the fixture to run them +# on. `level` says what shape to expect back. +check_measure_contract <- function(roster, .data, + level = c("node", "tie", "net", "mode")) { + level <- match.arg(level) + audit$covered <- c(audit$covered, names(roster)) + # A mode measure reports one value per mode rather than one per network. + expected <- switch(level, + node = manynet::net_nodes(.data), + tie = manynet::net_ties(.data), + net = 1L, + mode = 2L) + klass <- switch(level, + node = "node_measure", + tie = "tie_measure", + net = "network_measure", + mode = "mode_measure") + + for (fn in names(roster)) { + res <- call_measure(fn, roster[[fn]], .data) + + # Shape + expect_s3_class(res, klass) + expect_length(as.numeric(res), expected) + + # Declaration. Once a family's gaps reach zero these become hard + # expectations rather than notes; see `expect_declared()` below. + meas <- attr(res, "measure") + kind <- attr(res, "normalization") + rng <- attr(res, "range") + if (is.null(meas)) { + note_gap(fn, "declares no `measure` attribute") + } else { + expect_type(meas, "character") + } + if (is.null(kind)) { + note_gap(fn, "declares no `normalization` attribute") + } else { + expect_true(kind %in% netrics:::NORMALIZATIONS) + } + # Only measures offering a choice declare a `variant`, so its absence is + # not a gap. Where it is declared it must name exactly one variant. + varnt <- attr(res, "variant") + if (!is.null(varnt)) { + expect_type(varnt, "character") + expect_length(varnt, 1L) + } + + vals <- as.numeric(res) + vals <- vals[is.finite(vals)] + + # Range + if (is.null(rng)) { + note_gap(fn, "declares no `range` attribute") + } else if (length(vals) && + (min(vals) < rng[1] || max(vals) > rng[2])) { + note_gap(fn, sprintf("returned [%.3f, %.3f], outside its declared [%s, %s]", + min(vals), max(vals), rng[1], rng[2])) + } + + # Normalisation matches what the values show. A scaled measure divides by + # the observed maximum, so exactly one node must sit at 1; a proportion + # sums to one across all nodes. + if (!is.null(kind) && length(vals)) { + if (kind == "normalized" && !fn %in% unbounded_normalization && + (min(vals) < 0 || max(vals) > 1)) + note_gap(fn, "claims theoretical normalisation but leaves [0,1]") + if (kind == "scaled" && !isTRUE(all.equal(max(vals), 1))) + note_gap(fn, sprintf("claims scaling but its maximum is %.4f, not 1", max(vals))) + if (kind == "proportional" && !isTRUE(all.equal(sum(vals), 1))) + note_gap(fn, sprintf("claims proportional but its values sum to %.4f, not 1", sum(vals))) + } + + # Arguments are live rather than decorative + fargs <- formals(get(fn)) + for (flag in intersect(c("normalized", "scaled"), names(fargs))) { + if (flag %in% inert_arguments[[fn]]) next + # Toggle away from whatever the default is, rather than assuming it. + flipped <- !isTRUE(eval(fargs[[flag]])) + alt <- try(as.numeric(call_measure(fn, c(roster[[fn]], + stats::setNames(list(flipped), flag)), .data)), + silent = TRUE) + if (inherits(alt, "try-error")) { + note_gap(fn, sprintf("errors when `%s = %s`", flag, flipped)) + } else if (isTRUE(all.equal(as.numeric(res), alt))) { + note_gap(fn, sprintf("`%s` has no effect on the result", flag)) + } + } + + # `decay` is a number rather than a flag, so it is moved away from its + # default rather than negated. A measure that takes one should respond to + # it: 0.25 and 0.75 discount by visibly different amounts. + if ("decay" %in% names(fargs)) { + pair <- lapply(c(0.25, 0.75), function(d) + try(as.numeric(call_measure(fn, c(roster[[fn]], list(decay = d)), .data)), + silent = TRUE)) + if (any(vapply(pair, inherits, logical(1), "try-error"))) { + note_gap(fn, "errors on a `decay` within [0,1]") + } else if (isTRUE(all.equal(pair[[1]], pair[[2]]))) { + note_gap(fn, "`decay` has no effect on the result") + } + # The shared bound is enforced by `check_decay()`, so every measure + # taking a `decay` should refuse one outside [0,1]. + if (!inherits(try(call_measure(fn, c(roster[[fn]], list(decay = 1.5)), .data), + silent = TRUE), "try-error")) + note_gap(fn, "accepts a `decay` above 1") + } + + # Every choice of `method` should run, and should say which one ran, so + # that a result carrying no `variant` cannot be traced back to its method. + if ("method" %in% names(fargs)) { + for (m in eval(fargs$method)) { + alt <- try(call_measure(fn, c(roster[[fn]], list(method = m)), .data), + silent = TRUE) + if (inherits(alt, "try-error")) { + note_gap(fn, sprintf("errors when `method = \"%s\"`", m)) + } else if (is.null(attr(alt, "variant"))) { + note_gap(fn, sprintf("declares no `variant` for `method = \"%s\"`", m)) + } + } + } + } + succeed() +} + +# For families that have been brought fully under the contract: assert the +# three attributes are present rather than merely noting their absence. This +# is what stops the metadata rotting as new measures are added to a family. +expect_declared <- function(roster, .data) { + for (fn in names(roster)) { + res <- call_measure(fn, roster[[fn]], .data) + expect_false(is.null(attr(res, "measure")), + label = paste0(fn, " declares a `measure`")) + expect_false(is.null(attr(res, "range")), + label = paste0(fn, " declares a `range`")) + expect_false(is.null(attr(res, "normalization")), + label = paste0(fn, " declares a `normalization`")) + } +} + +# Every exported measure should be under the contract somewhere. A new +# `net_by_*()`, `node_by_*()`, or `tie_by_*()` that is not in any roster fails +# the build rather than quietly escaping the sweep. +exported_measures <- function() { + sort(grep("^(net|node|tie|mode)_by_", getNamespaceExports("netrics"), + value = TRUE)) +} + +report_contract_gaps <- function() { + if (length(audit$notes)) + message("Measure contract gaps (", length(unique(audit$notes)), "):\n ", + paste(unique(audit$notes), collapse = "\n ")) + invisible(NULL) +} diff --git a/tests/testthat/test-measure_centrality_contract.R b/tests/testthat/test-measure_centrality_contract.R index 95c78cb..a17c30e 100644 --- a/tests/testthat/test-measure_centrality_contract.R +++ b/tests/testthat/test-measure_centrality_contract.R @@ -1,168 +1,62 @@ -# Family-wide contract for the centrality measures. -# -# Rather than adding a test per function, this sweeps the whole roster and -# checks the promises the documentation makes: that a measure returns the -# right shape, that it stays inside the range it declares, that the -# normalisation it declares is the one it performed, and that its arguments -# actually do something. -# -# Where a function does not (yet) meet the contract, the sweep records an -# audit message rather than failing, so that the outstanding gaps are -# enumerated on every run instead of being either invisible or a red build. -# The list of audit messages is the remaining work; the aim is for it to -# shrink to empty. +# The centrality family's rosters are swept by the shared contract in +# helper-contract.R; what remains here is specific to this family. -audit <- new.env(parent = emptyenv()) -audit$notes <- character() - -note_gap <- function(fn, gap) { - audit$notes <- c(audit$notes, paste0(fn, ": ", gap)) - invisible(NULL) -} - -# The roster of node-level centrality measures, with any arguments needed to -# make them applicable. Adding a measure here brings it under the contract. -node_centralities <- list( - node_by_degree = list(), - node_by_deg = list(), - node_by_indegree = list(), - node_by_outdegree = list(), - node_by_leverage = list(), - node_by_closeness = list(), - node_by_harmonic = list(), - node_by_reach = list(), - node_by_decay = list(), - node_by_integration = list(), - node_by_radiality = list(), - node_by_eccentricity = list(), - node_by_vitality = list(), - node_by_randomwalk = list(), - node_by_betweenness = list(), - node_by_induced = list(), - node_by_eigenvector = list(), - node_by_power = list(), - node_by_alpha = list(), - node_by_pagerank = list(), - node_by_hub = list(), - node_by_authority = list(), - node_by_subgraph = list() -) - -call_measure <- function(fn, args, .data) { - do.call(fn, c(list(.data), args)) -} - -# Documented exemptions from the "arguments are live" contract. These are -# deliberate declarations, not gaps: an eigenvector is defined only up to a -# scalar multiple, so its scores carry no absolute units to preserve and -# scaling is intrinsic rather than optional. -inert_arguments <- list( - node_by_eigenvector = c("normalized", "scaled") -) +test_that("node centralities meet the measure contract", { + check_measure_contract(measure_rosters$centrality_node, + manynet::ison_adolescents, level = "node") + expect_declared(measure_rosters$centrality_node, manynet::ison_adolescents) +}) -test_that("node centralities return a node_measure of the right length", { - g <- manynet::ison_adolescents - n <- manynet::net_nodes(g) - for (fn in names(node_centralities)) { - res <- call_measure(fn, node_centralities[[fn]], g) - expect_s3_class(res, "node_measure") - expect_length(as.numeric(res), n) - } +test_that("multidegree meets the measure contract", { + check_measure_contract(measure_rosters$centrality_multiplex, + manynet::fict_marvel, level = "node") + expect_declared(measure_rosters$centrality_multiplex, manynet::fict_marvel) }) -test_that("node centralities declare what they measured", { - g <- manynet::ison_adolescents - for (fn in names(node_centralities)) { - res <- call_measure(fn, node_centralities[[fn]], g) - if (is.null(attr(res, "measure"))) { - note_gap(fn, "declares no `measure` attribute") - next - } - expect_type(attr(res, "measure"), "character") - expect_true(attr(res, "normalization") %in% netrics:::NORMALIZATIONS) - } +test_that("tie centralities meet the measure contract", { + check_measure_contract(measure_rosters$centrality_tie, + manynet::ison_adolescents, level = "tie") + expect_declared(measure_rosters$centrality_tie, manynet::ison_adolescents) }) -test_that("node centralities stay inside the range they declare", { - g <- manynet::ison_adolescents - for (fn in names(node_centralities)) { - res <- call_measure(fn, node_centralities[[fn]], g) - rng <- attr(res, "range") - if (is.null(rng)) { - note_gap(fn, "declares no `range` attribute") - next - } - vals <- as.numeric(res) - vals <- vals[is.finite(vals)] - if (!length(vals)) next - if (min(vals) < rng[1] || max(vals) > rng[2]) - note_gap(fn, sprintf("returned [%.3f, %.3f], outside its declared [%s, %s]", - min(vals), max(vals), rng[1], rng[2])) - } - succeed() +test_that("network centralisations meet the measure contract", { + check_measure_contract(measure_rosters$centrality_net, + manynet::ison_adolescents, level = "net") + expect_declared(measure_rosters$centrality_net, manynet::ison_adolescents) }) -test_that("declared normalisation matches what the values show", { - g <- manynet::ison_adolescents - for (fn in names(node_centralities)) { - res <- call_measure(fn, node_centralities[[fn]], g) - kind <- attr(res, "normalization") - if (is.null(kind)) next - vals <- as.numeric(res) - vals <- vals[is.finite(vals)] - if (!length(vals)) next - if (kind == "normalized" && (min(vals) < 0 || max(vals) > 1)) - note_gap(fn, "claims theoretical normalisation but leaves [0,1]") - # A scaled measure divides by the observed maximum, so exactly one node - # must sit at 1; a proportion sums to one across all nodes. - if (kind == "scaled" && !isTRUE(all.equal(max(vals), 1))) - note_gap(fn, sprintf("claims scaling but its maximum is %.4f, not 1", max(vals))) - if (kind == "proportion" && !isTRUE(all.equal(sum(vals), 1))) - note_gap(fn, sprintf("claims proportion but its values sum to %.4f, not 1", sum(vals))) - } - succeed() +test_that("mode centralisations meet the measure contract", { + check_measure_contract(measure_rosters$centrality_mode, + manynet::ison_southern_women, level = "mode") + expect_declared(measure_rosters$centrality_mode, + manynet::ison_southern_women) }) -test_that("arguments are live rather than decorative", { - g <- manynet::ison_adolescents - for (fn in names(node_centralities)) { - fargs <- formals(get(fn)) - base <- as.numeric(call_measure(fn, node_centralities[[fn]], g)) - for (flag in intersect(c("normalized", "scaled"), names(fargs))) { - if (flag %in% inert_arguments[[fn]]) next - # Toggle away from whatever the default is, rather than assuming it. - flipped <- !isTRUE(eval(fargs[[flag]])) - alt <- try(as.numeric(call_measure(fn, c(node_centralities[[fn]], - stats::setNames(list(flipped), flag)), g)), - silent = TRUE) - if (inherits(alt, "try-error")) { - note_gap(fn, sprintf("errors when `%s = %s`", flag, flipped)) - } else if (isTRUE(all.equal(base, alt))) { - note_gap(fn, sprintf("`%s` has no effect on the result", flag)) - } - } - } - succeed() +test_that("PN centrality meets the measure contract", { + signed <- manynet::to_uniplex(manynet::fict_marvel, "relationship") + check_measure_contract(measure_rosters$centrality_signed, signed, + level = "node") + expect_declared(measure_rosters$centrality_signed, signed) }) -test_that("measures dispatch on the information they are given", { +test_that("centralities dispatch on the information they are given", { g <- manynet::ison_adolescents w <- manynet::mutate_ties(g, weight = c(1, 2, 3, 1, 5, 1, 2, 8, 1, 3)) # Measures built only from the adjacency structure, which igraph provides # no weighted form of, are exempt. - exempt <- c("node_by_power", "node_by_subgraph", "node_by_leverage", + exempt <- c("node_by_power", "node_by_leverage", "node_by_reach", "node_by_deg", "node_by_indegree", "node_by_outdegree", "node_by_degree") - for (fn in setdiff(names(node_centralities), exempt)) { - unw <- as.numeric(call_measure(fn, node_centralities[[fn]], g)) - wtd <- try(as.numeric(call_measure(fn, node_centralities[[fn]], w)), - silent = TRUE) + roster <- measure_rosters$centrality_node + for (fn in setdiff(names(roster), exempt)) { + unw <- as.numeric(call_measure(fn, roster[[fn]], g)) + wtd <- try(as.numeric(call_measure(fn, roster[[fn]], w)), silent = TRUE) if (inherits(wtd, "try-error")) { note_gap(fn, "errors on a weighted network") } else if (isTRUE(all.equal(unw, wtd))) { note_gap(fn, "ignores tie weights") } - } + } succeed() }) @@ -209,11 +103,47 @@ test_that("renamed `scale` argument still works, with a warning", { expect_warning(node_by_power(g, scale = TRUE), "renamed") }) -# Reported last so that the gaps appear together at the end of the run. -test_that("outstanding contract gaps are recorded", { - if (length(audit$notes)) { - message("Centrality contract gaps (", length(audit$notes), "):\n ", - paste(unique(audit$notes), collapse = "\n ")) - } - succeed() +test_that("renamed `alpha` argument still works, with a warning", { + g <- manynet::ison_adolescents + expect_warning(node_by_alpha(g, alpha = 0.3), "renamed") + expect_equal(as.numeric(suppressWarnings(node_by_alpha(g, alpha = 0.3))), + as.numeric(node_by_alpha(g, decay = 0.3))) +}) + +test_that("subgraph centrality splits its walks as documented", { + g <- manynet::ison_adolescents + all <- as.numeric(node_by_subgraph(g)) + # At the default decay this is subgraph centrality as igraph computes it, + # so replacing that call with an eigendecomposition changed no results. + expect_equal(all, as.numeric(igraph::subgraph_centrality(manynet::as_igraph(g)))) + # Odd- and even-length closed walks partition the whole count. + expect_equal(as.numeric(node_by_subgraph(g, method = "odd")) + + as.numeric(node_by_subgraph(g, method = "even")), all) + # Each variant says which one it is. + expect_equal(attr(node_by_subgraph(g, method = "odd"), "variant"), "odd") + expect_equal(attr(node_by_subgraph(g, method = "odd"), "measure"), + "odd subgraph centrality") + # Discounting longer walks changes the scores but not their positivity. + expect_false(isTRUE(all.equal(as.numeric(node_by_subgraph(g, decay = 0.5)), all))) + expect_true(all(as.numeric(node_by_subgraph(g, decay = 0.5)) >= 1)) +}) + +test_that("bipartivity recognises a two-mode network", { + # A two-mode network admits no odd closed walk, so it is exactly bipartite. + expect_equal(as.numeric(net_by_bipartivity(manynet::ison_southern_women)), 1) + # A one-mode network with triangles falls short of it. + bip <- as.numeric(net_by_bipartivity(manynet::ison_adolescents)) + expect_true(bip > 0 && bip < 1) + # Bipartivity is the network-level share of what node_by_subgraph() splits. + expect_equal(bip, + sum(node_by_subgraph(manynet::ison_adolescents, method = "even")) / + sum(node_by_subgraph(manynet::ison_adolescents))) +}) + +test_that("pagerank responds to its decay", { + g <- manynet::ison_adolescents + expect_false(isTRUE(all.equal(as.numeric(node_by_pagerank(g, decay = 0.4)), + as.numeric(node_by_pagerank(g))))) + # Whatever the discount, the scores remain a distribution. + expect_equal(sum(node_by_pagerank(g, decay = 0.4)), 1) }) diff --git a/tests/testthat/test-measure_closure_contract.R b/tests/testthat/test-measure_closure_contract.R new file mode 100644 index 0000000..28edf95 --- /dev/null +++ b/tests/testthat/test-measure_closure_contract.R @@ -0,0 +1,50 @@ +test_that("network closures meet the measure contract", { + check_measure_contract(measure_rosters$closure_net, manynet::ison_networkers, + level = "net") + expect_declared(measure_rosters$closure_net, manynet::ison_networkers) +}) + +test_that("node closures meet the measure contract", { + check_measure_contract(measure_rosters$closure_node, + manynet::ison_adolescents, level = "node") + expect_declared(measure_rosters$closure_node, manynet::ison_adolescents) +}) + +test_that("closures stay bounded on two-mode and weighted networks", { + # These are proportions of configurations, so tie weights must not be able to + # push them above 1 - the reason both dichotomise their input. + sw <- manynet::ison_southern_women + expect_lte(as.numeric(net_by_equivalency(sw)), 1) + expect_true(all(as.numeric(node_by_equivalency(sw)) <= 1)) + nw <- manynet::ison_networkers + expect_lte(as.numeric(net_by_reciprocity(nw)), 1) + expect_true(all(as.numeric(node_by_reciprocity(nw)) <= 1)) +}) + +test_that("reciprocity records which of its two methods ran", { + # Both methods are normalised proportions in [0,1], but of different things, + # so the variant is what distinguishes the results rather than the range. + nw <- manynet::ison_networkers + expect_equal(attr(net_by_reciprocity(nw), "variant"), "default") + expect_equal(attr(net_by_reciprocity(nw, method = "ratio"), "variant"), "ratio") + expect_equal(attr(net_by_reciprocity(nw, method = "ratio"), "normalization"), + "normalized") + # A variant that says nothing about the values would be decorative; these + # two genuinely differ. + expect_false(isTRUE(all.equal(as.numeric(net_by_reciprocity(nw)), + as.numeric(net_by_reciprocity(nw, method = "ratio"))))) + # Unrecognised methods are now caught here rather than passed to igraph. + expect_error(net_by_reciprocity(nw, method = "nonsense")) +}) + +test_that("congruency meets the measure contract", { + # Congruency spans two two-mode networks, so the second mode of the first + # must match the first mode of the second. That shape does not fit the + # roster sweep, so it is checked here instead. + ring <- manynet::create_ring(c(10, 10)) + res <- net_by_congruency(ring, ring) + expect_s3_class(res, "network_measure") + expect_equal(attr(res, "measure"), "congruency") + expect_equal(attr(res, "range"), c(0, 1)) + expect_equal(attr(res, "normalization"), "normalized") +}) diff --git a/tests/testthat/test-measure_cohesion_contract.R b/tests/testthat/test-measure_cohesion_contract.R new file mode 100644 index 0000000..5bf8ded --- /dev/null +++ b/tests/testthat/test-measure_cohesion_contract.R @@ -0,0 +1,22 @@ +test_that("network cohesions meet the measure contract", { + check_measure_contract(measure_rosters$cohesion_net, + manynet::ison_adolescents, level = "net") + expect_declared(measure_rosters$cohesion_net, manynet::ison_adolescents) +}) + +test_that("network fragmentations meet the measure contract", { + ring <- manynet::create_ring(6) + check_measure_contract(measure_rosters$fragmentation_net, ring, level = "net") + expect_declared(measure_rosters$fragmentation_net, ring) +}) + +test_that("density stays a proportion whatever it is given", { + # The two-mode branch counts ties rather than summing weights, so a weighted + # network cannot report a density above 1. + sw <- manynet::ison_southern_women + w <- manynet::mutate_ties(sw, weight = rep(3, manynet::net_ties(sw))) + expect_equal(as.numeric(net_by_density(w)), as.numeric(net_by_density(sw))) + expect_lte(as.numeric(net_by_density(w)), 1) + expect_equal(as.numeric(net_by_density(manynet::create_filled(c(10, 6)))), 1) + expect_equal(as.numeric(net_by_density(manynet::create_empty(c(10, 6)))), 0) +}) diff --git a/tests/testthat/test-measure_diffusion_contract.R b/tests/testthat/test-measure_diffusion_contract.R new file mode 100644 index 0000000..0ad0d22 --- /dev/null +++ b/tests/testthat/test-measure_diffusion_contract.R @@ -0,0 +1,35 @@ +# Diffusion measures need a played diffusion rather than a plain network, so +# the fixture is seeded to keep the sweep deterministic. + +set.seed(2024) +smeg <- manynet::generate_smallworld(15, 0.025) +smeg_diff <- manynet::play_diffusion(smeg) + +test_that("network diffusion measures meet the measure contract", { + check_measure_contract(measure_rosters$diffusion_net, smeg_diff, level = "net") + expect_declared(measure_rosters$diffusion_net, smeg_diff) +}) + +test_that("node diffusion measures meet the measure contract", { + check_measure_contract(measure_rosters$diffusion_node, smeg_diff, + level = "node") + expect_declared(measure_rosters$diffusion_node, smeg_diff) +}) + +test_that("exposure meets the measure contract", { + check_measure_contract(measure_rosters$diffusion_exposure, smeg, + level = "node") + expect_declared(measure_rosters$diffusion_exposure, smeg) +}) + +test_that("herd immunity is never a negative proportion", { + # Below the epidemic threshold 1 - 1/R turns negative, which has no reading + # as a share of the network that needs protecting. + set.seed(1) + weak <- manynet::play_diffusion(manynet::generate_smallworld(20, 0.05), + transmissibility = 0.2, recovery = 0.3) + expect_lt(as.numeric(net_by_reproduction(weak)), 1) + expect_equal(as.numeric(net_by_immunity(weak)), 0) + expect_equal(as.numeric(net_by_immunity(weak, normalized = FALSE)), 0) + expect_lte(as.numeric(net_by_immunity(smeg_diff)), 1) +}) diff --git a/tests/testthat/test-measure_features_contract.R b/tests/testthat/test-measure_features_contract.R new file mode 100644 index 0000000..aaea307 --- /dev/null +++ b/tests/testthat/test-measure_features_contract.R @@ -0,0 +1,46 @@ +test_that("network features meet the measure contract", { + check_measure_contract(measure_rosters$features_net, + manynet::ison_adolescents, level = "net") + expect_declared(measure_rosters$features_net, manynet::ison_adolescents) +}) + +test_that("structural fit measures meet the measure contract", { + check_measure_contract(measure_rosters$fit_net, manynet::ison_adolescents, + level = "net") + expect_declared(measure_rosters$fit_net, manynet::ison_adolescents) +}) + +test_that("structural balance meets the measure contract", { + signed <- manynet::to_uniplex(manynet::fict_marvel, "relationship") + check_measure_contract(measure_rosters$features_balance, signed, level = "net") + expect_declared(measure_rosters$features_balance, signed) +}) + +test_that("measures on several scales report which one they are on", { + # net_by_core()'s methods return a correlation, a distance, and two signed + # differences, so a single range would be wrong for three of the four. + g <- manynet::ison_adolescents + expect_equal(attr(net_by_core(g), "range"), c(-1, 1)) + expect_equal(attr(net_by_core(g, method = "ident"), "measure"), + "core-periphery distance") + expect_equal(attr(net_by_core(g, method = "ident"), "range"), c(0, Inf)) + # Sigma is a ratio of ratios with no upper bound; omega and SWI are bounded. + expect_equal(attr(net_by_smallworld(g, method = "sigma", times = 20), "range"), + c(0, Inf)) + # Which of the three coefficients ran is recorded as a variant rather than + # spelled into the measure name, so the measure stays the same across them. + expect_equal(attr(net_by_smallworld(g, times = 20), "measure"), + "small-world coefficient") + expect_equal(attr(net_by_smallworld(g, times = 20), "variant"), "omega") + expect_equal(attr(net_by_smallworld(g, method = "SWI", times = 20), "variant"), + "SWI") + # A variant is orthogonal to a normalisation: SWI is both. + expect_equal(attr(net_by_smallworld(g, method = "SWI", times = 20), + "normalization"), "normalized") + expect_equal(attr(net_by_core(g, method = "ident"), "variant"), "ident") + # The modularity floor moves with the resolution, so the range follows it. + memb <- node_in_partition(g) + expect_equal(attr(net_by_modularity(g, memb), "range"), c(-0.5, 1)) + expect_equal(attr(net_by_modularity(g, memb, resolution = 2), "range"), + c(-Inf, 1)) +}) diff --git a/tests/testthat/test-measure_heterogeneity_contract.R b/tests/testthat/test-measure_heterogeneity_contract.R new file mode 100644 index 0000000..a7209a3 --- /dev/null +++ b/tests/testthat/test-measure_heterogeneity_contract.R @@ -0,0 +1,52 @@ +# `marvel_friends` has both a categorical ("Gender") and a numeric +# ("Appearances") attribute, which is what makes the runtime index +# substitution testable. + +marvel_friends <- manynet::to_unsigned( + manynet::to_uniplex(manynet::fict_marvel, "relationship"), "positive") + +test_that("network heterogeneities meet the measure contract", { + check_measure_contract(measure_rosters$heterogeneity_net, marvel_friends, + level = "net") + expect_declared(measure_rosters$heterogeneity_net, marvel_friends) +}) + +test_that("node heterogeneities meet the measure contract", { + check_measure_contract(measure_rosters$heterogeneity_node, marvel_friends, + level = "node") + expect_declared(measure_rosters$heterogeneity_node, marvel_friends) +}) + +test_that("spatial autocorrelation meets the measure contract", { + check_measure_contract(measure_rosters$heterogeneity_spatial, + manynet::ison_lawfirm, level = "net") + expect_declared(measure_rosters$heterogeneity_spatial, manynet::ison_lawfirm) +}) + +test_that("measures report the index they actually used", { + # Blau's index is inapplicable to a numeric attribute, so the function + # substitutes the coefficient of variation. The reported label is the only + # record of that substitution, so it must follow the substitution. + expect_equal(attr(net_by_diversity(marvel_friends, "Gender"), "measure"), + "Blau's index") + cv <- net_by_diversity(marvel_friends, "Appearances") + expect_equal(attr(cv, "measure"), "coefficient of variation") + expect_equal(attr(cv, "normalization"), "none") + # The variant is read off the resolved index too, so it records the + # substitution rather than the index that was asked for. + expect_equal(attr(cv, "variant"), "variation") + expect_equal(attr(net_by_diversity(marvel_friends, "Gender"), "variant"), + "blau") + expect_equal(attr(net_by_homophily(marvel_friends, "Gender", + assortativity = "yule"), "variant"), + "yule") + expect_equal(attr(net_by_diversity(marvel_friends, "Appearances", + diversity = "gini"), "measure"), + "Gini coefficient") + # And the same for the assortativity indices. + expect_equal(attr(net_by_homophily(marvel_friends, "Gender"), "measure"), + "IE index") + expect_equal(attr(net_by_homophily(marvel_friends, "Gender", + assortativity = "yule"), "measure"), + "Yule's Q") +}) diff --git a/tests/testthat/test-measure_hierarchy.R b/tests/testthat/test-measure_hierarchy.R index ea5bdc2..a1a15f2 100644 --- a/tests/testthat/test-measure_hierarchy.R +++ b/tests/testthat/test-measure_hierarchy.R @@ -21,14 +21,21 @@ test_that("net_efficiency works correctly", { # Basic functionality tests effic_judo <- net_by_efficiency(ison_judo_moves) - # Return type tests + # Return type and range tests expect_true(is.numeric(as.numeric(effic_judo))) - expect_true(as.numeric(effic_judo) > 0) + expect_true(as.numeric(effic_judo) >= 0) + expect_true(as.numeric(effic_judo) <= 1) + + # A tree carries no ties beyond those needed to connect it, and a complete + # network carries every tie it could; these are the endpoints of the scale. + expect_equal(as.numeric(net_by_efficiency(create_tree(8))), 1) + expect_equal(as.numeric(net_by_efficiency(create_empty(5))), 1) + expect_equal(as.numeric(net_by_efficiency(create_filled(6))), 0) }) test_that("net_upperbound works correctly", { # Basic functionality tests - upper_judo <- net_by_efficiency(ison_judo_moves) + upper_judo <- net_by_upperbound(ison_judo_moves) # Return type and range tests expect_true(is.numeric(as.numeric(upper_judo))) @@ -53,6 +60,7 @@ test_that("net_x_hierarchy works correctly", { expect_true(all(sapply(result, is.numeric))) expect_true(result$Connectedness >= 0 && result$Connectedness <= 1) expect_true(result$InvReciprocity >= 0 && result$InvReciprocity <= 1) - expect_true(result$Efficiency > 0) # Should be positive (can be > 1) + # All four dimensions are on [0,1], which is what makes them comparable + expect_true(result$Efficiency >= 0 && result$Efficiency <= 1) expect_true(result$LeastUpperBound >= 0 && result$LeastUpperBound <= 1) }) diff --git a/tests/testthat/test-measure_holes_contract.R b/tests/testthat/test-measure_holes_contract.R new file mode 100644 index 0000000..f977085 --- /dev/null +++ b/tests/testthat/test-measure_holes_contract.R @@ -0,0 +1,18 @@ +test_that("node hole measures meet the measure contract", { + check_measure_contract(measure_rosters$holes_node, + manynet::ison_adolescents, level = "node") + expect_declared(measure_rosters$holes_node, manynet::ison_adolescents) +}) + +test_that("node hole measures meet the contract on two-mode data", { + check_measure_contract(measure_rosters$holes_node[c("node_by_efficiency", + "node_by_constraint", + "node_by_hierarchy")], + manynet::ison_southern_women, level = "node") +}) + +test_that("tie hole measures meet the measure contract", { + check_measure_contract(measure_rosters$holes_tie, + manynet::ison_adolescents, level = "tie") + expect_declared(measure_rosters$holes_tie, manynet::ison_adolescents) +}) diff --git a/tests/testthat/test-measure_misc_contract.R b/tests/testthat/test-measure_misc_contract.R new file mode 100644 index 0000000..4eb28f1 --- /dev/null +++ b/tests/testthat/test-measure_misc_contract.R @@ -0,0 +1,38 @@ +# Hierarchy, change, brokerage, and coreness: small families that share the +# same fixtures rather than warranting a roster file each. + +test_that("hierarchy measures meet the measure contract", { + check_measure_contract(measure_rosters$hierarchy_net, + manynet::ison_networkers, level = "net") + expect_declared(measure_rosters$hierarchy_net, manynet::ison_networkers) +}) + +test_that("the hierarchy dimensions share a scale", { + # net_x_hierarchy() only compares its four dimensions meaningfully if they + # all run from 0 to 1. + out <- net_x_hierarchy(manynet::ison_networkers) + expect_true(all(vapply(out, function(x) x >= 0 && x <= 1, + FUN.VALUE = logical(1)))) + # A tree carries no ties beyond those that connect it; a complete network + # carries every tie it could. + expect_equal(as.numeric(net_by_efficiency(manynet::create_tree(8))), 1) + expect_equal(as.numeric(net_by_efficiency(manynet::create_filled(6))), 0) +}) + +test_that("coreness measures meet the measure contract", { + check_measure_contract(measure_rosters$core_node, + manynet::ison_adolescents, level = "node") + expect_declared(measure_rosters$core_node, manynet::ison_adolescents) +}) + +test_that("brokerage measures meet the measure contract", { + check_measure_contract(measure_rosters$brokerage_node, + manynet::ison_networkers, level = "node") + expect_declared(measure_rosters$brokerage_node, manynet::ison_networkers) +}) + +test_that("net_by_waves meets the measure contract", { + check_measure_contract(measure_rosters$change_net, manynet::fict_thrones, + level = "net") + expect_declared(measure_rosters$change_net, manynet::fict_thrones) +}) diff --git a/tests/testthat/test-measure_nodes.R b/tests/testthat/test-measure_nodes.R index ee8ca29..adedfd1 100644 --- a/tests/testthat/test-measure_nodes.R +++ b/tests/testthat/test-measure_nodes.R @@ -10,7 +10,6 @@ for(fn in names(node_meas)) { for (ob in names(data_objs)) { test_that(paste(fn, "works on", ob), { skip_if(grepl("multideg", fn)) - skip_if(grepl("equivalency", fn) && ob == "labelled") if(grepl("diversity|richness|heterophily|homophily", fn)){ if(ob == "attribute") expect_s3_class(node_meas[[fn]](data_objs[[ob]], "group"), "node_measure") else diff --git a/tests/testthat/test-measure_registry_contract.R b/tests/testthat/test-measure_registry_contract.R new file mode 100644 index 0000000..5b018e5 --- /dev/null +++ b/tests/testthat/test-measure_registry_contract.R @@ -0,0 +1,23 @@ +# The registry check. A newly added measure that is not in any roster should +# fail the build rather than quietly escaping the contract sweep, so this +# compares the rosters in helper-contract.R against the namespace itself. + +test_that("every exported measure is under the contract", { + rostered <- unique(unlist(lapply(measure_rosters, names))) + uncovered <- setdiff(exported_measures(), + c(rostered, uncontracted_measures)) + expect_equal(uncovered, character(0), + label = "measures missing from every roster") +}) + +test_that("rosters name measures that actually exist", { + rostered <- unique(unlist(lapply(measure_rosters, names))) + expect_equal(setdiff(rostered, exported_measures()), character(0), + label = "rostered names not exported by netrics") +}) + +# Reported last so that any outstanding gaps appear together at the end. +test_that("outstanding contract gaps are recorded", { + report_contract_gaps() + succeed() +}) From ae4ca7f6806e518a548bf17f2969e843815cf831 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 07:36:57 +0200 Subject: [PATCH 40/68] Fixed `net_by_balance()` erroring on networks that hold signs as negative weights --- NEWS.md | 1 + R/measure_features.R | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index ff3ba11..065574f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -82,6 +82,7 @@ - Fixed `node_by_diversity()` reporting an undefined object in its message about substituting an inapplicable index - Corrected `net_by_transmissibility()` to no longer declare itself a proportion - At-risk denominator recorded at the end of each period rather than the start, so can exceed 1 +- Fixed `net_by_balance()` erroring on networks that hold signs as negative weights, which is how 'stocnet' objects keep them - Added family-wide contract test sweeping every measure for declared ranges, normalisation, and argument effects ## Memberships diff --git a/R/measure_features.R b/R/measure_features.R index dba9315..5529013 100644 --- a/R/measure_features.R +++ b/R/measure_features.R @@ -271,15 +271,24 @@ net_by_bipartivity <- function(.data) { net_by_balance <- function(.data) { .data <- manynet::expect_nodes(.data) + # A sign is held either as a "sign" tie attribute or as the sign of a + # negative weight, which is how 'stocnet' objects keep it. + .tie_signs <- function(g){ + if ("sign" %in% igraph::edge_attr_names(g)) + igraph::edge_attr(g, "sign") + else if ("weight" %in% igraph::edge_attr_names(g)) + sign(igraph::edge_attr(g, "weight")) + else NULL + } .count_signed_triangles <- function(.data){ g <- manynet::as_igraph(.data) - if (!"sign" %in% igraph::edge_attr_names(g)) { - manynet::snet_abort("network does not have a sign edge attribute") - } if (igraph::is_directed(g)) { manynet::snet_abort("g must be undirected") } - eattrV <- igraph::edge_attr(g, "sign") + eattrV <- .tie_signs(g) + if (is.null(eattrV)) { + manynet::snet_abort("network does not have a sign edge attribute") + } if (!all(eattrV %in% c(-1, 1))) { manynet::snet_abort("sign may only contain -1 and 1") } @@ -321,7 +330,7 @@ net_by_balance <- function(.data) { manynet::snet_abort("object must be undirected") } g <- manynet::as_igraph(.data) - eattrV <- igraph::edge_attr(g, "sign") + eattrV <- .tie_signs(g) if (!all(eattrV %in% c(-1, 1))) { manynet::snet_abort("sign may only contain -1 and 1") } From 36fc1eb69c6760ca0cbf71e89ab88c7befd59b04 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 07:37:55 +0200 Subject: [PATCH 41/68] Fixed `node_x_tie()` erroring on diffusion models, where nodes change over waves but ties do not --- NEWS.md | 2 ++ R/motif_census.R | 9 +++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/NEWS.md b/NEWS.md index 065574f..ca31b34 100644 --- a/NEWS.md +++ b/NEWS.md @@ -105,6 +105,8 @@ - Each branches on whether the attribute given is categorical or continuous - For two-mode networks, `node_x_similarity()` compares each node with those at distance two - These are the nodes it shares a node of the other mode with, following the tertius effect of `{migraph}` and `{goldfish}` (Haunss and Hollway 2023) +- Fixed `node_x_tie()` erroring on diffusion models, where nodes change over waves but ties do not + - `node_in_equivalence()` and `node_in_structural()` also erred on such networks, since they call `node_x_tie()` - Added `net_x_homophily()`, returning the table behind the EI index together with an expected-EI baseline and Yule's Q - Note that on weighted networks this counts ties where `net_by_heterophily()` sums weights, so the two agree only when unweighted diff --git a/R/motif_census.R b/R/motif_census.R index 3133d37..1b9deaf 100644 --- a/R/motif_census.R +++ b/R/motif_census.R @@ -25,7 +25,8 @@ NULL node_x_tie <- function(.data){ .data <- manynet::expect_nodes(.data) object <- manynet::as_igraph(.data) - # edge_names <- net_tie_attributes(object) + # Only tie-level waves split the census; a diffusion model's ties do not change + waved <- "wave" %in% manynet::net_tie_attributes(object) if (manynet::is_directed(object)) { if (manynet::is_multiplex(.data)) { mat <- do.call(rbind, lapply(unique(manynet::tie_attribute(object, "type")), @@ -33,7 +34,7 @@ node_x_tie <- function(.data){ rc <- manynet::as_matrix(manynet::to_uniplex(object, x)) rbind(rc, t(rc)) })) - } else if (manynet::is_longitudinal(object)){ + } else if (waved){ mat <- do.call(rbind, lapply(unique(manynet::tie_attribute(object, "wave")), function(x){ rc <- manynet::as_matrix(manynet::to_waves(object)[[x]]) @@ -50,7 +51,7 @@ node_x_tie <- function(.data){ function(x){ manynet::as_matrix(manynet::to_uniplex(object, x)) })) - } else if (manynet::is_longitudinal(object)){ + } else if (waved){ mat <- do.call(rbind, lapply(unique(manynet::tie_attribute(object, "wave")), function(x){ manynet::as_matrix(manynet::to_waves(object)[[x]]) @@ -67,7 +68,7 @@ node_x_tie <- function(.data){ paste0("to", manynet::node_names(object))), unique(manynet::tie_attribute(object, "type"))), 1, paste, collapse = "_") - } else if (manynet::is_longitudinal(object)){ + } else if (waved){ rownames(mat) <- apply(expand.grid(c(paste0("from", manynet::node_names(object)), paste0("to", manynet::node_names(object))), unique(manynet::tie_attribute(object, "wave"))), From a63eab8f6cf09c012a4a05b576bfc9b5995b23f7 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 09:31:16 +0200 Subject: [PATCH 42/68] Added `k=` to community detection functions to target a specific number of communities (thanks @tomasdiviak) --- NEWS.md | 11 + R/member_community.R | 454 +++++++++++++++++++------ R/method_k.R | 54 +-- inst/tutorials/netrics2/community.Rmd | 7 +- man-roxygen/param_k.R | 18 + man/member_community.Rd | 22 +- man/member_community_hier.Rd | 33 +- man/member_community_non.Rd | 46 ++- man/method_kselect.Rd | 7 + tests/testthat/test-member_community.R | 79 +++++ vignettes/articles/community.Rmd | 45 ++- 11 files changed, 625 insertions(+), 151 deletions(-) create mode 100644 man-roxygen/param_k.R diff --git a/NEWS.md b/NEWS.md index ca31b34..721220a 100644 --- a/NEWS.md +++ b/NEWS.md @@ -87,7 +87,18 @@ ## Memberships +- Added `k=` to community detection functions to target a specific number of communities (thanks @tomasdiviak) + - `node_in_betweenness()`, `node_in_greedy()`, `node_in_eigen()`, and `node_in_walktrap()` cut their dendrograms at `k` + - `node_in_louvain()` and `node_in_leiden()` search the resolution parameter for the value that returns `k` + - `node_in_fluid()` passes `k` straight to the algorithm, which also makes it much faster + - `node_in_labels()` seeds `k` fixed labels and merges any surplus groups by modularity + - `node_in_partition()` is now a k-way Kernighan-Lin, and no longer returns only two groups + - `node_in_community()` considers only these algorithms when `k` is given + - `k` also accepts `"silhouette"`, `"elbow"`, and `"strict"`, as in `node_in_equivalence()` + - Note `k=` is now the second argument, so positional calls such as `node_in_louvain(x, 0.5)` must become `node_in_louvain(x, resolution = 0.5)` - Added `node_in_labels()` for label propagation community detection +- Fixed `node_in_community()` returning nothing but an error whenever verbosity was not `"verbose"` +- Renamed `times=` in `node_in_walktrap()` to `steps=`, which is more descriptive and consistent with `{igraph}` - Added `node_in_block()` for direct blockmodelling, searching partitions for the one that minimises `net_by_inconsistency()` - Fixed `node_in_regular()` to compute regular equivalence using recursive similarity between nodes rather than a triad census - Choose between `regularity = "rolesim"` (default) and `"rege"` diff --git a/R/member_community.R b/R/member_community.R index 119e442..54e337e 100644 --- a/R/member_community.R +++ b/R/member_community.R @@ -1,5 +1,142 @@ # Community clustering #### +# Helpers for targeting a number of communities #### + +# Validates `k`, returning NULL, a single integer, or a method name. +check_k <- function(k, .data){ + if(is.null(k)) return(NULL) + if(is.character(k)) return(match.arg(k, c("silhouette", "elbow", "strict"))) + if(!is.numeric(k) || length(k) != 1 || k < 1 || k %% 1 != 0) + manynet::snet_abort("`k` must be a single positive integer,", + "or one of {.val silhouette}, {.val elbow}, {.val strict}.") + if(k > manynet::net_nodes(.data)) + manynet::snet_abort("`k` cannot exceed the number of nodes.") + as.integer(k) +} + +# Geodesic distances, for scoring candidate partitions. +node_dists <- function(.data){ + d <- igraph::distances(manynet::as_igraph(.data)) + d[is.infinite(d)] <- manynet::net_nodes(.data) # unconnected pairs + d +} + +# Mean silhouette width of one membership vector. +# Note this is the per-partition core of `k_silhouette()`, which cannot be +# called here because it reads `hc$distances`, which only +# `cluster_hierarchical()` attaches and community dendrograms lack. +sil_score <- function(memb, d){ + if(length(unique(memb)) < 2) return(NA_real_) + mean(vapply(seq_along(memb), function(i){ + wig <- which(memb == memb[i]) + wig <- wig[wig != i] + # a node alone in its group scores 0, per Rousseeuw + if(length(wig) == 0) return(0) + ai <- mean(d[i, wig]) + wog <- which(memb != memb[i]) + bi <- min(vapply(unique(memb[wog]), + function(b) mean(d[i, wog[memb[wog] == b]]), + FUN.VALUE = numeric(1))) + (bi - ai)/max(ai, bi) + }, FUN.VALUE = numeric(1))) +} + +# Share of ties that fall inside a group. +coverage <- function(.data, memb){ + e <- igraph::as_edgelist(manynet::as_igraph(.data), names = FALSE) + if(nrow(e) == 0) return(0) + mean(memb[e[,1]] == memb[e[,2]]) +} + +# Selects among candidate partitions, given in increasing k. +select_k <- function(parts, .data, method){ + ks <- vapply(parts, function(p) length(unique(p)), FUN.VALUE = integer(1)) + if(method == "silhouette"){ + d <- node_dists(.data) + scores <- vapply(parts, sil_score, d = d, FUN.VALUE = numeric(1)) + if(all(is.na(scores))) return(parts[[1]]) + parts[[which.max(scores)]] + } else { + cvs <- vapply(parts, function(p) coverage(.data, p), FUN.VALUE = numeric(1)) + parts[[which(ks == elbow_point(ks, cvs))[1]]] + } +} + +# Greedily merges the pair of groups whose merge best preserves modularity. +merge_to_k <- function(.data, memb, k){ + gr <- manynet::as_igraph(.data) + while(length(unique(memb)) > k){ + gs <- unique(memb) + best <- NULL + bestq <- -Inf + for(i in seq_along(gs)) for(j in seq_along(gs)) if(i < j){ + cand <- memb + cand[cand == gs[j]] <- gs[i] + q <- igraph::modularity(gr, as.integer(factor(cand))) + if(q > bestq){ bestq <- q; best <- c(gs[i], gs[j]) } + } + memb[memb == best[2]] <- best[1] + } + as.integer(factor(memb)) +} + +# Bisects the resolution parameter of `fun` to reach k communities. +# The number of communities rises with the resolution, but not strictly, +# so the best result found is kept and the iteration cap stops the search. +cut_res <- function(fun, gr, k, lower = 1e-6, upper = 100, iter = 40){ + best <- NULL + bestk <- NA + for(i in seq_len(iter)){ + mid <- (lower + upper)/2 + memb <- fun(gr, resolution = mid)$membership + found <- length(unique(memb)) + if(is.na(bestk) || abs(found - k) < abs(bestk - k)){ + best <- memb + bestk <- found + } + if(found == k) return(memb) + if(found < k) lower <- mid else upper <- mid + } + best +} + +# Cuts a hierarchical clustering at `no` groups. +# The merge tree can be incomplete, on an unconnected network or where the +# algorithm stopped splitting. igraph then warns and returns more groups than +# asked, which `report_k()` reports in the package's own style. +cut_tree <- function(clust, no){ + tryCatch(suppressWarnings(igraph::cut_at(clust, no = no)), + error = function(e) clust$membership) +} + +# Warns where the requested number of communities was not reached. +report_k <- function(memb, k){ + found <- length(unique(memb)) + if(is.numeric(k) && found != k) + manynet::snet_warn("This algorithm returns {found} communities here,", + "and not the {k} requested.") + memb +} + +# The partition in which no tie crosses a group. +strict_memb <- function(.data){ + manynet::snet_info("Returning the components partition,", + "in which no tie crosses a group.") + igraph::components(manynet::as_igraph(.data))$membership +} + +# Resolves `k` for one algorithm. +# `at_k(no)` returns a membership vector with `no` groups, +# and `default()` returns the algorithm's own partition. +apply_k <- function(k, Kmax, .data, at_k, default){ + n <- manynet::net_nodes(.data) + memb <- if(is.null(k)) default() else + if(identical(k, "strict")) strict_memb(.data) else + if(is.character(k)) select_k(lapply(2:min(Kmax, n), at_k), .data, k) else + at_k(k) + report_k(memb, k) +} + #' Memberships in communities #' @name member_community #' @description @@ -14,52 +151,89 @@ #' returns that membership vector. #' #' @template param_data +#' @template param_k #' @family community #' @template node_member NULL #' @rdname member_community #' @export -node_in_community <- function(.data){ +node_in_community <- function(.data, k = NULL, Kmax = 8L){ .data <- manynet::expect_nodes(.data) - if(manynet::net_nodes(.data)<100){ + k <- check_k(k, .data) + if(is.null(k) && manynet::net_nodes(.data)<100){ # don't use node_in_betweenness because slow and poorer quality to optimal manynet::snet_success("{.fn node_in_optimal} available and", "will return the highest modularity partition.") netrics::node_in_optimal(.data) } else { - manynet::snet_info("Excluding {.fn node_in_optimal} because network rather large.") - poss_algs <- c("node_in_infomap", - "node_in_spinglass", - "node_in_fluid", - "node_in_louvain", - "node_in_leiden", - "node_in_greedy", - "node_in_eigen", - "node_in_walktrap") + if(is.null(k)){ + manynet::snet_info("Excluding {.fn node_in_optimal} because network rather large.") + poss_algs <- c("node_in_infomap", + "node_in_spinglass", + "node_in_fluid", + "node_in_louvain", + "node_in_leiden", + "node_in_greedy", + "node_in_eigen", + "node_in_walktrap") + } else { + manynet::snet_info("Considering only those algorithms that accept {.arg k}.") + poss_algs <- c("node_in_fluid", + "node_in_louvain", + "node_in_leiden", + "node_in_labels", + "node_in_partition", + "node_in_greedy", + "node_in_eigen", + "node_in_walktrap", + "node_in_betweenness") + } + if(manynet::net_nodes(.data)>=100){ + notforlarge <- intersect(poss_algs, "node_in_betweenness") + if(length(notforlarge)){ + manynet::snet_info("Excluding {.fn {notforlarge}} because network rather large.") + poss_algs <- setdiff(poss_algs, notforlarge) + } + } if(!manynet::is_connected(.data)){ - notforconnected <- c("node_in_spinglass", - "node_in_fluid") - manynet::snet_info("Excluding {.fn {notforconnected}} because network unconnected.") - poss_algs <- setdiff(poss_algs, notforconnected) + notforconnected <- intersect(poss_algs, c("node_in_spinglass", + "node_in_fluid")) + if(length(notforconnected)){ + manynet::snet_info("Excluding {.fn {notforconnected}} because network unconnected.") + poss_algs <- setdiff(poss_algs, notforconnected) + } } if(manynet::is_directed(.data)){ - notfordirected <- c("node_in_louvain", - "node_in_leiden", - "node_in_eigen") - manynet::snet_info("Excluding {.fn {notfordirected}} because network directed.") - poss_algs <- setdiff(poss_algs, notfordirected) + notfordirected <- intersect(poss_algs, c("node_in_louvain", + "node_in_leiden", + "node_in_labels", + "node_in_partition", + "node_in_eigen")) + if(length(notfordirected)){ + manynet::snet_info("Excluding {.fn {notfordirected}} because network directed.") + poss_algs <- setdiff(poss_algs, notfordirected) + } } manynet::snet_info("Considering each of {.fn {poss_algs}}.") - candidates <- lapply(manynet::snet_progress_along(poss_algs), function(comm){ - memb <- get(poss_algs[comm])(.data) + # `snet_progress_along()` returns nothing unless verbosity is "verbose", + # so fall back to a plain sequence to keep the loop running when quiet + idx <- manynet::snet_progress_along(poss_algs) + if(length(idx) != length(poss_algs)) idx <- seq_along(poss_algs) + candidates <- lapply(idx, function(comm){ + memb <- if(is.null(k)) get(poss_algs[comm])(.data) else + suppressWarnings(get(poss_algs[comm])(.data, k = k, Kmax = Kmax)) mod <- net_by_modularity(.data, memb) list(memb, mod) }) mods <- unlist(sapply(candidates, "[", 2)) maxmod <- which.max(mods) manynet::snet_success("{.fn {poss_algs[maxmod]}} returns the highest modularity ({round(mods[maxmod],3)}).") - candidates[[maxmod]][[1]] + out <- candidates[[maxmod]][[1]] + if(is.numeric(k) && length(unique(out)) != k) + manynet::snet_warn("No available algorithm returns {k} communities here.", + "Returning {length(unique(out))} instead.") + out } } @@ -117,6 +291,7 @@ node_in_community <- function(.data){ #' and their logic or domain of inspiration. #' #' @template param_data +#' @template param_k #' @family community #' @template node_member NULL @@ -152,10 +327,12 @@ node_in_optimal <- function(.data){ #' where the net tie cost of a node is the difference between the sum #' of the weights of ties to nodes in the other group (external costs) and #' the sum of the weights of ties to nodes in the same group (internal costs). +#' Where `k` is greater than two, the same swap pass is run for every pair of +#' groups, and the rounds repeat until no swap improves the partition. #' This is a deterministic algorithm that will always return the same partition #' for a given network, but it is not guaranteed to maximise modularity. #' Note that this algorithm is only applicable to undirected, unipartite networks, -#' and will always return two communities of equal size (or as close to equal as possible). +#' and returns `k` communities of equal size (or as close to equal as possible). #' @references #' ## On partitioning community detection #' Kernighan, Brian W., and Shen Lin. 1970. @@ -166,50 +343,59 @@ node_in_optimal <- function(.data){ #' node_in_partition(ison_adolescents) #' node_in_partition(ison_southern_women) #' @export -node_in_partition <- function(.data){ +node_in_partition <- function(.data, k = 2L, Kmax = 8L){ .data <- manynet::expect_nodes(.data) - # assign groups arbitrarily + k <- check_k(k, .data) n <- manynet::net_nodes(.data) - group_size <- ifelse(n %% 2 == 0, n/2, (n+1)/2) - - # count internal and external costs of each node g <- manynet::as_matrix(manynet::to_multilevel(.data)) - g1 <- g[1:group_size, 1:group_size] - g2 <- g[(group_size+1):n, (group_size+1):n] - intergroup <- g[1:group_size, (group_size+1):n] - - g2.intcosts <- rowSums(g2) - g2.extcosts <- colSums(intergroup) - - g1.intcosts <- rowSums(g1) - g1.extcosts <- rowSums(intergroup) - - # count edge costs of each nodes - g1.net <- g1.extcosts - g1.intcosts - g2.net <- g2.extcosts - g2.intcosts - - g1.net <- sort(g1.net, decreasing = TRUE) - g2.net <- sort(g2.net, decreasing = TRUE) - - # swap pairs of nodes (one from each group) that give a positive sum of net tie costs - if(length(g1.net)!=length(g2.net)) { - g2.net <- c(g2.net,0) - } else {g2.net} - - sums <- as.integer(unname(g1.net + g2.net)) - # positions in sequence of names at which sum >= 0 - index <- which(sums >= 0 %in% sums) - g1.newnames <- g1.names <- names(g1.net) - g2.newnames <- g2.names <- names(g2.net) - # make swaps based on positions in sequence - for (i in index) { - g1.newnames[i] <- g2.names[i] - g2.newnames[i] <- g1.names[i] + at_k <- function(no) kl_partition(g, n, no) + memb <- apply_k(k, Kmax, .data, at_k = at_k, default = function() at_k(2L)) + make_node_member(memb, .data) +} + +# One pass of net-cost swaps between two groups. +# The net cost of a node is the sum of the weights of its ties to the other +# group (external) less the sum of the weights of its ties within its own +# group (internal). Pairs whose net costs sum to zero or more are swapped. +kl_swap <- function(g, a, b){ + intergroup <- g[a, b, drop = FALSE] + a.net <- rowSums(intergroup) - rowSums(g[a, a, drop = FALSE]) + b.net <- colSums(intergroup) - rowSums(g[b, b, drop = FALSE]) + a.ord <- a[order(a.net, decreasing = TRUE)] + b.ord <- b[order(b.net, decreasing = TRUE)] + a.sort <- sort(a.net, decreasing = TRUE) + b.sort <- sort(b.net, decreasing = TRUE) + len <- min(length(a.sort), length(b.sort)) + if(len == 0) return(list(a = a, b = b, swapped = FALSE)) + index <- which(a.sort[seq_len(len)] + b.sort[seq_len(len)] >= 0) + if(length(index) == 0) return(list(a = a, b = b, swapped = FALSE)) + a.new <- a.ord + b.new <- b.ord + a.new[index] <- b.ord[index] + b.new[index] <- a.ord[index] + list(a = a.new, b = b.new, swapped = TRUE) +} + +# k-way Kernighan-Lin. Nodes start in k groups of near-equal size, in node +# order, and every pair of groups is swept until no round makes a swap. +kl_partition <- function(g, n, k, rounds = 50){ + memb <- sort(rep(seq_len(k), length.out = n)) + groups <- lapply(seq_len(k), function(i) which(memb == i)) + for(r in seq_len(rounds)){ + moved <- FALSE + for(i in seq_len(k)) for(j in seq_len(k)) if(i < j){ + res <- kl_swap(g, groups[[i]], groups[[j]]) + if(res$swapped){ + groups[[i]] <- res$a + groups[[j]] <- res$b + moved <- TRUE + } + } + if(!moved) break } - - # extract names of vertices in each group after swaps - out <- ifelse(manynet::node_names(.data) %in% g1.newnames, 1, 2) - make_node_member(out, .data) + out <- integer(n) + for(i in seq_len(k)) out[groups[[i]]] <- i + out } #' @rdname member_community_non @@ -303,8 +489,9 @@ node_in_spinglass <- function(.data, max_k = 200, resolution = 1){ #' @examples #' node_in_fluid(ison_adolescents) #' @export -node_in_fluid <- function(.data) { +node_in_fluid <- function(.data, k = NULL, Kmax = 8L) { .data <- manynet::expect_nodes(.data) + k <- check_k(k, .data) .data <- manynet::as_igraph(.data) if (!igraph::is_connected(.data)) { manynet::snet_unavailable("This algorithm only works for connected networks.", @@ -321,13 +508,16 @@ node_in_fluid <- function(.data) { "Converting to undirected") .data <- manynet::to_undirected(.data) } - mods <- vapply(seq_nodes(.data), function(x) - igraph::modularity(.data, membership = igraph::membership( - igraph::cluster_fluid_communities(.data, x))), - FUN.VALUE = numeric(1)) - out <- igraph::membership(igraph::cluster_fluid_communities( - .data, no.of.communities = which.max(mods))) - make_node_member(out, .data) + at_k <- function(no) igraph::membership( + igraph::cluster_fluid_communities(.data, no.of.communities = no)) + memb <- apply_k(k, Kmax, .data, at_k = at_k, default = function(){ + mods <- vapply(seq_nodes(.data), function(x) + igraph::modularity(.data, membership = igraph::membership( + igraph::cluster_fluid_communities(.data, x))), + FUN.VALUE = numeric(1)) + at_k(which.max(mods)) + }) + make_node_member(memb, .data) } } @@ -339,6 +529,8 @@ node_in_fluid <- function(.data) { #' When no further modularity-increasing reassignments are possible, #' the resulting communities are considered nodes (like a reduced graph), #' and the process continues. +#' Where `k` is given, the resolution parameter is searched for the value +#' that returns that number of communities, and `resolution` is ignored. #' @references #' ## On Louvain community detection #' Blondel, Vincent, Jean-Loup Guillaume, Renaud Lambiotte, Etienne Lefebvre. 2008. @@ -347,17 +539,20 @@ node_in_fluid <- function(.data) { #' @examples #' node_in_louvain(ison_adolescents) #' @export -node_in_louvain <- function(.data, resolution = 1){ +node_in_louvain <- function(.data, k = NULL, Kmax = 8L, resolution = 1){ .data <- manynet::expect_nodes(.data) + k <- check_k(k, .data) if(manynet::is_directed(.data)){ manynet::snet_info("This algorithm only works for undirected networks.", "Converting to undirected") .data <- manynet::to_undirected(.data) } - out <- igraph::cluster_louvain(manynet::as_igraph(.data), - resolution = resolution - )$membership - make_node_member(out, .data) + gr <- manynet::as_igraph(.data) + memb <- apply_k(k, Kmax, .data, + at_k = function(no) cut_res(igraph::cluster_louvain, gr, no), + default = function() + igraph::cluster_louvain(gr, resolution = resolution)$membership) + make_node_member(memb, .data) } #' @rdname member_community_non @@ -377,6 +572,8 @@ node_in_louvain <- function(.data, resolution = 1){ #' _i_ and _j_ are in the same communities and 0 otherwise. #' Compared to the Louvain method, the Leiden algorithm additionally #' tries to avoid unconnected communities. +#' Where `k` is given, the resolution parameter is searched for the value +#' that returns that number of communities, and `resolution` is ignored. #' @references #' ## On Leiden community detection #' Traag, Vincent A., Ludo Waltman, and Nees Jan van Eck. 2019. @@ -386,21 +583,24 @@ node_in_louvain <- function(.data, resolution = 1){ #' @examples #' node_in_leiden(ison_adolescents) #' @export -node_in_leiden <- function(.data, resolution = 1){ +node_in_leiden <- function(.data, k = NULL, Kmax = 8L, resolution = 1){ .data <- manynet::expect_nodes(.data) + k <- check_k(k, .data) if(manynet::is_directed(.data)){ manynet::snet_info("This algorithm only works for undirected networks.", "Converting to undirected") .data <- manynet::to_undirected(.data) } - if(manynet::is_weighted(.data)){ # Traag resolution default + if(is.null(k) && manynet::is_weighted(.data)){ # Traag resolution default n <- manynet::net_nodes(.data) resolution <- sum(manynet::tie_weights(.data))/(n*(n - 1)/2) } - out <- igraph::cluster_leiden(manynet::as_igraph(.data), - resolution = resolution - )$membership - make_node_member(out, .data) + gr <- manynet::as_igraph(.data) + memb <- apply_k(k, Kmax, .data, + at_k = function(no) cut_res(igraph::cluster_leiden, gr, no), + default = function() + igraph::cluster_leiden(gr, resolution = resolution)$membership) + make_node_member(memb, .data) } #' @rdname member_community_non @@ -420,6 +620,14 @@ node_in_leiden <- function(.data, resolution = 1){ #' and on sparse networks it may return a single community. #' Set a seed for reproducibility, or use `node_in_community()` to select #' among algorithms by modularity. +#' +#' Where `k` is given, the algorithm becomes semi-supervised. +#' The `k` nodes of highest degree are each given a distinct, fixed label, +#' every other node starts with a label of its own, +#' and propagation runs as normal. +#' Seeding alone tends to leave more than `k` labels standing, +#' so any surplus groups are then merged in the order that best preserves +#' modularity, until exactly `k` communities remain. #' @references #' ## On label propagation community detection #' Raghavan, Usha Nandini, Reka Albert, and Soundar Kumara. 2007. @@ -429,16 +637,31 @@ node_in_leiden <- function(.data, resolution = 1){ #' @examples #' node_in_labels(ison_adolescents) #' @export -node_in_labels <- function(.data){ +node_in_labels <- function(.data, k = NULL, Kmax = 8L){ .data <- manynet::expect_nodes(.data) + k <- check_k(k, .data) if(manynet::is_directed(.data)){ manynet::snet_info("This algorithm only works for undirected networks.", "Converting to undirected") .data <- manynet::to_undirected(.data) } - out <- igraph::cluster_label_prop(manynet::as_igraph(.data) - )$membership - make_node_member(out, .data) + gr <- manynet::as_igraph(.data) + n <- manynet::net_nodes(.data) + at_k <- function(no){ + if(no >= n) return(seq_len(n)) + seeds <- order(igraph::degree(gr), decreasing = TRUE)[seq_len(no)] + init <- seq_len(n) + init[seeds] <- seq_len(no) + init[-seeds] <- (no + 1):n + fixed <- rep(FALSE, n) + fixed[seeds] <- TRUE + memb <- suppressWarnings(igraph::cluster_label_prop( + gr, initial = init, fixed = fixed)$membership) + merge_to_k(.data, memb, no) + } + memb <- apply_k(k, Kmax, .data, at_k = at_k, + default = function() igraph::cluster_label_prop(gr)$membership) + make_node_member(memb, .data) } # Hierarchical community clustering #### @@ -463,6 +686,7 @@ node_in_labels <- function(.data){ #' and their logic or domain of inspiration. #' #' @template param_data +#' @template param_k #' @template node_member #' @family community NULL @@ -486,18 +710,21 @@ NULL #' @examples #' node_in_betweenness(ison_adolescents) #' @export -node_in_betweenness <- function(.data){ +node_in_betweenness <- function(.data, k = NULL, Kmax = 8L){ .data <- manynet::expect_nodes(.data) + k <- check_k(k, .data) if(manynet::net_nodes(.data)>100) manynet::snet_warn("This algorithm may take some time", "or even run out of memory on such a large network.") clust <- suppressWarnings(igraph::cluster_edge_betweenness( manynet::as_igraph(.data))) - out <- clust$membership - out <- make_node_member(out, .data) + memb <- apply_k(k, Kmax, .data, + at_k = function(no) cut_tree(clust, no), + default = function() clust$membership) + out <- make_node_member(memb, .data) attr(out, "hc") <- stats::as.hclust(clust, use.modularity = igraph::is_connected(.data)) - attr(out, "k") <- max(clust$membership) + attr(out, "k") <- length(unique(memb)) out } @@ -519,15 +746,17 @@ node_in_betweenness <- function(.data){ #' @examples #' node_in_greedy(ison_adolescents) #' @export -node_in_greedy <- function(.data){ +node_in_greedy <- function(.data, k = NULL, Kmax = 8L){ .data <- manynet::expect_nodes(.data) + k <- check_k(k, .data) clust <- igraph::cluster_fast_greedy(manynet::to_undirected(manynet::as_igraph(.data))) - out <- clust$membership - make_node_member(out, .data) - out <- make_node_member(out, .data) + memb <- apply_k(k, Kmax, .data, + at_k = function(no) cut_tree(clust, no), + default = function() clust$membership) + out <- make_node_member(memb, .data) attr(out, "hc") <- stats::as.hclust(clust, use.modularity = igraph::is_connected(.data)) - attr(out, "k") <- max(clust$membership) + attr(out, "k") <- length(unique(memb)) out } @@ -548,19 +777,21 @@ node_in_greedy <- function(.data){ #' @examples #' node_in_eigen(ison_adolescents) #' @export -node_in_eigen <- function(.data){ +node_in_eigen <- function(.data, k = NULL, Kmax = 8L){ .data <- manynet::expect_nodes(.data) + k <- check_k(k, .data) if(manynet::is_directed(.data)){ manynet::snet_info("This algorithm only works for undirected networks.", "Converting to undirected") .data <- manynet::to_undirected(.data) } - clust <- igraph::cluster_leading_eigen(as_igraph(.data)) - out <- clust$membership - make_node_member(out, .data) - out <- make_node_member(out, .data) + clust <- igraph::cluster_leading_eigen(manynet::as_igraph(.data)) + memb <- apply_k(k, Kmax, .data, + at_k = function(no) cut_tree(clust, no), + default = function() clust$membership) + out <- make_node_member(memb, .data) attr(out, "hc") <- stats::as.hclust(clust) - attr(out, "k") <- max(clust$membership) + attr(out, "k") <- length(unique(memb)) out } @@ -570,8 +801,9 @@ node_in_eigen <- function(.data){ #' within the same community because few edges lead outside a community. #' By repeating random walks of 4 steps many times, #' information about the hierarchical merging of communities is collected. -#' @param times Integer indicating number of simulations/walks used. -#' By default, `times=50`. +#' @param steps Integer indicating the length of the random walks. +#' By default `steps = 4`, as in `{igraph}`. +#' Longer walks reach further and tend to return fewer, larger communities. #' @references #' ## On walktrap community detection #' Pons, Pascal, and Matthieu Latapy. 2005. @@ -581,15 +813,17 @@ node_in_eigen <- function(.data){ #' @examples #' node_in_walktrap(ison_adolescents) #' @export -node_in_walktrap <- function(.data, times = 50){ +node_in_walktrap <- function(.data, k = NULL, Kmax = 8L, steps = 4){ .data <- manynet::expect_nodes(.data) - clust <- igraph::cluster_walktrap(manynet::as_igraph(.data)) - out <- clust$membership - make_node_member(out, .data) - out <- make_node_member(out, .data) + k <- check_k(k, .data) + clust <- igraph::cluster_walktrap(manynet::as_igraph(.data), steps = steps) + memb <- apply_k(k, Kmax, .data, + at_k = function(no) cut_tree(clust, no), + default = function() clust$membership) + out <- make_node_member(memb, .data) attr(out, "hc") <- stats::as.hclust(clust, use.modularity = igraph::is_connected(.data)) - attr(out, "k") <- max(clust$membership) + attr(out, "k") <- length(unique(memb)) out } diff --git a/R/method_k.R b/R/method_k.R index 740c6c3..27bbbaf 100644 --- a/R/method_k.R +++ b/R/method_k.R @@ -23,6 +23,29 @@ #' @name method_kselect NULL +# Locates the elbow of a curve: the point furthest from the straight line +# drawn between the curve's first and last points. +elbow_point <- function(x_values, y_values) { + # Max values to create line + if(min(x_values)==1) x_values <- x_values[2:length(x_values)] + if(min(y_values)==0) y_values <- y_values[2:length(y_values)] + max_df <- data.frame(x = c(min(x_values), max(x_values)), + y = c(min(y_values), max(y_values))) + # Creating straight line between the max values + fit <- stats::lm(max_df$y ~ max_df$x) + # Distance from point to line + distances <- vector() + for (i in seq_len(length(x_values))) { + distances <- c(distances, + abs(stats::coef(fit)[2]*x_values[i] - + y_values[i] + + stats::coef(fit)[1]) / + sqrt(stats::coef(fit)[2]^2 + 1^2)) + } + # Max distance point + x_values[which.max(distances)] +} + #' @rdname method_kselect #' @section Strict method: #' The strict method selects the number of clusters in which there is no @@ -52,6 +75,13 @@ k_strict <- function(hc, .data){ #' The point at which the elbow occurs is often considered a good choice for #' the number of clusters, as it represents a balance between #' model complexity and fit to the data. +#' +#' The elbow is located geometrically. +#' A straight line is drawn between the first and the last point of the curve. +#' The perpendicular distance from each point to this line is measured, +#' and the point at the greatest distance is the elbow. +#' Note that where the curve is close to a straight line, +#' no point stands out and the method returns one of the endpoints. #' @references #' ## On the elbow method #' Thorndike, Robert L. 1953. @@ -80,28 +110,6 @@ k_elbow <- function(hc, .data, motif, Kmax){ cluster_cor_mat } - elbow_finder <- function(x_values, y_values) { - # Max values to create line - if(min(x_values)==1) x_values <- x_values[2:length(x_values)] - if(min(y_values)==0) y_values <- y_values[2:length(y_values)] - max_df <- data.frame(x = c(min(x_values), max(x_values)), - y = c(min(y_values), max(y_values))) - # Creating straight line between the max values - fit <- stats::lm(max_df$y ~ max_df$x) - # Distance from point to line - distances <- vector() - for (i in seq_len(length(x_values))) { - distances <- c(distances, - abs(stats::coef(fit)[2]*x_values[i] - - y_values[i] + - coef(fit)[1]) / - sqrt(stats::coef(fit)[2]^2 + 1^2)) - } - # Max distance point - x_max_dist <- x_values[which.max(distances)] - x_max_dist - } - vertices <- manynet::net_nodes(.data) observedcorrelation <- cor(t(motif)) @@ -126,7 +134,7 @@ k_elbow <- function(hc, .data, motif, Kmax){ correct <- NULL # to satisfy the error god # k identification method - elbow_finder(dafr$clusters, dafr$correlations) + elbow_point(dafr$clusters, dafr$correlations) } #' @rdname method_kselect diff --git a/inst/tutorials/netrics2/community.Rmd b/inst/tutorials/netrics2/community.Rmd index d5f7e8c..dbabad1 100644 --- a/inst/tutorials/netrics2/community.Rmd +++ b/inst/tutorials/netrics2/community.Rmd @@ -1105,10 +1105,11 @@ They call this the "maximum modularity partition" and insert the parenthetical computationally-prohibitive exhaustive enumeration (Brandes et al. 2008))." So let's try and get a community classification using the walktrap algorithm, `node_in_walktrap()`, -with path lengths of the random walks specified to be 50. +with the random walks set to four steps, which is the default. +Longer walks reach further, and tend to return fewer, larger communities. ```{r walk, exercise=TRUE, exercise.setup = "manip-fri"} -friend_wt <- node_in_walktrap(friends, times=50) +friend_wt <- node_in_walktrap(friends, steps = 4) ``` ```{r walk-hint-1, purl = FALSE} @@ -1132,7 +1133,7 @@ net_by_modularity(friends, friend_wt) ``` ```{r walk-solution} -friend_wt <- node_in_walktrap(friends, times=50) +friend_wt <- node_in_walktrap(friends, steps = 4) # results in a modularity of net_by_modularity(friends, friend_wt) ``` diff --git a/man-roxygen/param_k.R b/man-roxygen/param_k.R new file mode 100644 index 0000000..b764e20 --- /dev/null +++ b/man-roxygen/param_k.R @@ -0,0 +1,18 @@ +#' @param k Integer indicating the target number of communities to return. +#' By default `NULL`, in which case the algorithm returns the number of +#' communities that it finds itself. +#' Alternatively, a character string naming a selection method: +#' `"silhouette"` selects the number that maximises the mean silhouette +#' width over geodesic distances, `"elbow"` selects the number at the +#' elbow of the coverage curve, and `"strict"` returns the partition in +#' which no tie crosses a group, i.e. the components. +#' Prefer `"silhouette"`; the elbow method is unreliable where the +#' coverage curve has no clear elbow. +#' If the algorithm cannot return exactly the number of communities +#' requested, a warning is given and the nearest number is returned. +#' @param Kmax Integer indicating the maximum number of communities to +#' evaluate for `"silhouette"` and `"elbow"`. By default `8`. +#' Otherwise ignored. +#' Note that for `node_in_louvain()` and `node_in_leiden()` each candidate +#' requires its own search over the resolution parameter, +#' so a large `Kmax` is costly on large networks. diff --git a/man/member_community.Rd b/man/member_community.Rd index 3480866..08a7038 100644 --- a/man/member_community.Rd +++ b/man/member_community.Rd @@ -5,12 +5,32 @@ \alias{node_in_community} \title{Memberships in communities} \usage{ -node_in_community(.data) +node_in_community(.data, k = NULL, Kmax = 8L) } \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{k}{Integer indicating the target number of communities to return. +By default \code{NULL}, in which case the algorithm returns the number of +communities that it finds itself. +Alternatively, a character string naming a selection method: +\code{"silhouette"} selects the number that maximises the mean silhouette +width over geodesic distances, \code{"elbow"} selects the number at the +elbow of the coverage curve, and \code{"strict"} returns the partition in +which no tie crosses a group, i.e. the components. +Prefer \code{"silhouette"}; the elbow method is unreliable where the +coverage curve has no clear elbow. +If the algorithm cannot return exactly the number of communities +requested, a warning is given and the nearest number is returned.} + +\item{Kmax}{Integer indicating the maximum number of communities to +evaluate for \code{"silhouette"} and \code{"elbow"}. By default \code{8}. +Otherwise ignored. +Note that for \code{node_in_louvain()} and \code{node_in_leiden()} each candidate +requires its own search over the resolution parameter, +so a large \code{Kmax} is costly on large networks.} } \value{ A \code{node_member} character vector the length of the nodes in the network, diff --git a/man/member_community_hier.Rd b/man/member_community_hier.Rd index 717df2a..1d76151 100644 --- a/man/member_community_hier.Rd +++ b/man/member_community_hier.Rd @@ -8,21 +8,42 @@ \alias{node_in_walktrap} \title{Memberships in hierarchical communities} \usage{ -node_in_betweenness(.data) +node_in_betweenness(.data, k = NULL, Kmax = 8L) -node_in_greedy(.data) +node_in_greedy(.data, k = NULL, Kmax = 8L) -node_in_eigen(.data) +node_in_eigen(.data, k = NULL, Kmax = 8L) -node_in_walktrap(.data, times = 50) +node_in_walktrap(.data, k = NULL, Kmax = 8L, steps = 4) } \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{times}{Integer indicating number of simulations/walks used. -By default, \code{times=50}.} +\item{k}{Integer indicating the target number of communities to return. +By default \code{NULL}, in which case the algorithm returns the number of +communities that it finds itself. +Alternatively, a character string naming a selection method: +\code{"silhouette"} selects the number that maximises the mean silhouette +width over geodesic distances, \code{"elbow"} selects the number at the +elbow of the coverage curve, and \code{"strict"} returns the partition in +which no tie crosses a group, i.e. the components. +Prefer \code{"silhouette"}; the elbow method is unreliable where the +coverage curve has no clear elbow. +If the algorithm cannot return exactly the number of communities +requested, a warning is given and the nearest number is returned.} + +\item{Kmax}{Integer indicating the maximum number of communities to +evaluate for \code{"silhouette"} and \code{"elbow"}. By default \code{8}. +Otherwise ignored. +Note that for \code{node_in_louvain()} and \code{node_in_leiden()} each candidate +requires its own search over the resolution parameter, +so a large \code{Kmax} is costly on large networks.} + +\item{steps}{Integer indicating the length of the random walks. +By default \code{steps = 4}, as in \code{{igraph}}. +Longer walks reach further and tend to return fewer, larger communities.} } \value{ A \code{node_member} character vector the length of the nodes in the network, diff --git a/man/member_community_non.Rd b/man/member_community_non.Rd index 25cc419..13d960d 100644 --- a/man/member_community_non.Rd +++ b/man/member_community_non.Rd @@ -14,25 +14,45 @@ \usage{ node_in_optimal(.data) -node_in_partition(.data) +node_in_partition(.data, k = 2L, Kmax = 8L) node_in_infomap(.data, times = 50) node_in_spinglass(.data, max_k = 200, resolution = 1) -node_in_fluid(.data) +node_in_fluid(.data, k = NULL, Kmax = 8L) -node_in_louvain(.data, resolution = 1) +node_in_louvain(.data, k = NULL, Kmax = 8L, resolution = 1) -node_in_leiden(.data, resolution = 1) +node_in_leiden(.data, k = NULL, Kmax = 8L, resolution = 1) -node_in_labels(.data) +node_in_labels(.data, k = NULL, Kmax = 8L) } \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{k}{Integer indicating the target number of communities to return. +By default \code{NULL}, in which case the algorithm returns the number of +communities that it finds itself. +Alternatively, a character string naming a selection method: +\code{"silhouette"} selects the number that maximises the mean silhouette +width over geodesic distances, \code{"elbow"} selects the number at the +elbow of the coverage curve, and \code{"strict"} returns the partition in +which no tie crosses a group, i.e. the components. +Prefer \code{"silhouette"}; the elbow method is unreliable where the +coverage curve has no clear elbow. +If the algorithm cannot return exactly the number of communities +requested, a warning is given and the nearest number is returned.} + +\item{Kmax}{Integer indicating the maximum number of communities to +evaluate for \code{"silhouette"} and \code{"elbow"}. By default \code{8}. +Otherwise ignored. +Note that for \code{node_in_louvain()} and \code{node_in_leiden()} each candidate +requires its own search over the resolution parameter, +so a large \code{Kmax} is costly on large networks.} + \item{times}{Integer indicating number of simulations/walks used. By default, \code{times=50}.} @@ -90,10 +110,12 @@ swap pairs of nodes (one from each group) that give a positive sum of net tie co where the net tie cost of a node is the difference between the sum of the weights of ties to nodes in the other group (external costs) and the sum of the weights of ties to nodes in the same group (internal costs). +Where \code{k} is greater than two, the same swap pass is run for every pair of +groups, and the rounds repeat until no swap improves the partition. This is a deterministic algorithm that will always return the same partition for a given network, but it is not guaranteed to maximise modularity. Note that this algorithm is only applicable to undirected, unipartite networks, -and will always return two communities of equal size (or as close to equal as possible). +and returns \code{k} communities of equal size (or as close to equal as possible). } \section{Infomap}{ @@ -133,6 +155,8 @@ each node is moved to the community where it achieves the highest contribution t When no further modularity-increasing reassignments are possible, the resulting communities are considered nodes (like a reduced graph), and the process continues. +Where \code{k} is given, the resolution parameter is searched for the value +that returns that number of communities, and \code{resolution} is ignored. } \section{Leiden}{ @@ -152,6 +176,8 @@ and \eqn{\delta(\sigma_i, \sigma_j) = 1} if and only if \emph{i} and \emph{j} are in the same communities and 0 otherwise. Compared to the Louvain method, the Leiden algorithm additionally tries to avoid unconnected communities. +Where \code{k} is given, the resolution parameter is searched for the value +that returns that number of communities, and \code{resolution} is ignored. } \section{Label propagation}{ @@ -171,6 +197,14 @@ the same network can return different partitions, and on sparse networks it may return a single community. Set a seed for reproducibility, or use \code{node_in_community()} to select among algorithms by modularity. + +Where \code{k} is given, the algorithm becomes semi-supervised. +The \code{k} nodes of highest degree are each given a distinct, fixed label, +every other node starts with a label of its own, +and propagation runs as normal. +Seeding alone tends to leave more than \code{k} labels standing, +so any surplus groups are then merged in the order that best preserves +modularity, until exactly \code{k} communities remain. } \examples{ diff --git a/man/method_kselect.Rd b/man/method_kselect.Rd index 7fbb90a..7a75473 100644 --- a/man/method_kselect.Rd +++ b/man/method_kselect.Rd @@ -72,6 +72,13 @@ correlation as the number of clusters increases. The point at which the elbow occurs is often considered a good choice for the number of clusters, as it represents a balance between model complexity and fit to the data. + +The elbow is located geometrically. +A straight line is drawn between the first and the last point of the curve. +The perpendicular distance from each point to this line is measured, +and the point at the greatest distance is the elbow. +Note that where the curve is close to a straight line, +no point stands out and the method returns one of the endpoints. } \section{Silhouette method}{ diff --git a/tests/testthat/test-member_community.R b/tests/testthat/test-member_community.R index 9c6a038..af9110c 100644 --- a/tests/testthat/test-member_community.R +++ b/tests/testthat/test-member_community.R @@ -44,3 +44,82 @@ test_that("label propagation membership works", { expect_length(node_in_labels(ison_networkers), manynet::net_nodes(ison_networkers)) }) + +# Target number of communities #### + +test_that("every k-capable algorithm returns exactly k communities", { + fns <- list(betweenness = node_in_betweenness, greedy = node_in_greedy, + walktrap = node_in_walktrap, louvain = node_in_louvain, + leiden = node_in_leiden, fluid = node_in_fluid, + labels = node_in_labels, partition = node_in_partition) + for(nm in names(fns)) for(k in 2:4){ + set.seed(1234) + res <- fns[[nm]](ison_adolescents, k = k) + expect_s3_class(res, "node_member") + 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 + set.seed(1234) + expect_s3_class(node_in_eigen(ison_adolescents, k = 3), "node_member") +}) + +test_that("k is recorded in the k attribute of hierarchical memberships", { + expect_equal(attr(node_in_betweenness(ison_adolescents, k = 3), "k"), 3) + expect_equal(attr(node_in_greedy(ison_adolescents, k = 4), "k"), 4) + expect_equal(attr(node_in_walktrap(ison_adolescents, k = 2), "k"), 2) +}) + +test_that("k is validated", { + expect_error(node_in_louvain(ison_adolescents, k = 0)) + expect_error(node_in_louvain(ison_adolescents, k = 1000)) + expect_error(node_in_louvain(ison_adolescents, k = 0.5)) + expect_error(node_in_louvain(ison_adolescents, k = c(2,3))) + expect_error(node_in_louvain(ison_adolescents, k = "nonsense")) +}) + +test_that("k accepts the selection methods", { + for(nm in c("node_in_betweenness", "node_in_greedy", "node_in_walktrap", + "node_in_louvain", "node_in_leiden", "node_in_fluid", + "node_in_labels", "node_in_partition")){ + set.seed(1234) + sil <- get(nm)(ison_adolescents, k = "silhouette") + expect_s3_class(sil, "node_member") + expect_gte(length(unique(sil)), 2) + set.seed(1234) + expect_s3_class(get(nm)(ison_adolescents, k = "elbow"), "node_member") + } + # strict returns the components, so one community on a connected network + expect_equal(length(unique(node_in_betweenness(ison_adolescents, + k = "strict"))), 1) +}) + +test_that("an unreachable k warns and returns the nearest", { + options(snet_verbosity = "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_message(node_in_betweenness(unconn, k = 1), "communities") + expect_equal(length(unique(node_in_betweenness(unconn, k = 1))), 2) + options(snet_verbosity = "quiet") +}) + +test_that("node_in_partition preserves its two-group result", { + expect_equal(unname(as.character(node_in_partition(ison_adolescents))), + c("B","A","A","A","B","B","A","B")) + expect_equal(unname(as.character(node_in_partition(ison_adolescents, k = 2))), + c("B","A","A","A","B","B","A","B")) +}) + +test_that("node_in_community accepts k", { + set.seed(1234) + res <- node_in_community(ison_adolescents, k = 3) + expect_s3_class(res, "node_member") + expect_equal(length(unique(res)), 3) +}) + +test_that("node_in_walktrap passes steps to igraph", { + expect_s3_class(node_in_walktrap(ison_adolescents, steps = 2), "node_member") + expect_length(node_in_walktrap(ison_adolescents, steps = 8), + net_nodes(ison_adolescents)) +}) diff --git a/vignettes/articles/community.Rmd b/vignettes/articles/community.Rmd index 0e35403..88a80a5 100644 --- a/vignettes/articles/community.Rmd +++ b/vignettes/articles/community.Rmd @@ -760,10 +760,11 @@ They call this the "maximum modularity partition" and insert the parenthetical computationally-prohibitive exhaustive enumeration (Brandes et al. 2008))." So let's try and get a community classification using the walktrap algorithm, `node_in_walktrap()`, -with path lengths of the random walks specified to be 50. +with the random walks set to four steps, which is the default. +Longer walks reach further, and tend to return fewer, larger communities. ```{r walk} -friend_wt <- node_in_walktrap(friends, times=50) +friend_wt <- node_in_walktrap(friends, steps = 4) ``` @@ -870,6 +871,45 @@ and `node_in_eigen()` (spectral). See `?member_community` for the full list with definitions and references. ::: +### A target number of communities {#target-k} + +Each of these algorithms decides for itself how many communities there are, +usually by maximising modularity. +Sometimes you want a particular number instead, +for example because you want to compare the result against +an attribute that already has three categories. +Pass that number as `k`: + +```{r targetk} +node_in_walktrap(friends, k = 3) +node_in_louvain(friends, k = 3) +``` + +The route to `k` differs by algorithm. +The hierarchical algorithms cut their dendrogram at `k`. +`node_in_louvain()` and `node_in_leiden()` search their resolution parameter +for the value that returns `k` communities. +`node_in_fluid()` and `node_in_partition()` take `k` directly. +`node_in_labels()` fixes the labels of the `k` best-connected nodes, +propagates from there, and merges any surplus groups. + +Not every network can be split into any number of communities. +Where the requested number is out of reach, +the function says so and returns the nearest partition it can find. + +`k` also accepts the name of a selection method, +which chooses the number for you on a criterion other than modularity: + +```{r selectk} +node_in_betweenness(friends, k = "silhouette") +``` + +`"silhouette"` prefers the number of communities where nodes sit close to +others in their own community and far from the nearest other community. +`"strict"` returns the partition in which no tie crosses a group at all, +which is the components. +These are the same method names that `node_in_equivalence()` accepts. + ::: {.callout} **In brief**: Community detection algorithms cluster nodes by tie density: `node_in_walktrap()` via random walks, @@ -936,6 +976,7 @@ Along the way, you have learned to use these functions: | `node_attribute()` | extracts a nodal attribute, e.g. as an empirical membership | | `node_in_walktrap()`, `node_in_betweenness()`, `node_in_greedy()` | community detection via random walks, divisive tie removal, agglomerative merges | | `node_in_community()` | surveys applicable algorithms and returns the best assignment by modularity | +| `node_in_*(k = )` | targets a number of communities, or names a method (`"silhouette"`, `"strict"`) to choose it | | `mutate_nodes()`, `mutate_ties()` | adds measures or memberships to the network for graphing | | `graphr(..., node_color = , node_group = , edge_color = )` | maps memberships onto colours and group outlines | From b314812948316d7e2999ed96828f7e6f98cb76a7 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 17:07:46 +0200 Subject: [PATCH 43/68] Added coreness methods for core-periphery analysis --- NAMESPACE | 4 + NEWS.md | 5 + R/method_coreness.R | 382 +++++++++++++++++++++++++++++++++++ man-roxygen/param_coreness.R | 10 + man/method_coreness.Rd | 201 ++++++++++++++++++ 5 files changed, 602 insertions(+) create mode 100644 R/method_coreness.R create mode 100644 man-roxygen/param_coreness.R create mode 100644 man/method_coreness.Rd diff --git a/NAMESPACE b/NAMESPACE index 30282af..27e7079 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -3,6 +3,10 @@ export(cluster_concor) export(cluster_cosine) export(cluster_hierarchical) +export(coreness_correlation) +export(coreness_hub) +export(coreness_richcore) +export(coreness_transition) export(k_elbow) export(k_gap) export(k_silhouette) diff --git a/NEWS.md b/NEWS.md index 721220a..fdf2804 100644 --- a/NEWS.md +++ b/NEWS.md @@ -125,6 +125,11 @@ - Added `regularity_rolesim()` and `regularity_rege()`, recursive role similarity methods - Note `regularity_rege()` is degenerate on unweighted connected networks, where it warns +- Added coreness methods for core-periphery analysis, each returning mark, member, and measure + - `coreness_correlation()` is Borgatti and Everett's continuous model, fixed to exclude self-ties and to start its search from the degree ordering rather than from a flat vector, where the correlation is undefined + - `coreness_richcore()` is Ma and Mondragon's rich-core, which reads tie weights and tie direction directly, and is the only method that runs on a two-mode network + - `coreness_transition()` is Rombach and colleagues' core score, aggregated over a grid of boundary sharpness and core size + - `coreness_hub()` is Elliott and colleagues' directed core-periphery, distinguishing an out-core from an in-core ## Tutorials diff --git a/R/method_coreness.R b/R/method_coreness.R new file mode 100644 index 0000000..425e70b --- /dev/null +++ b/R/method_coreness.R @@ -0,0 +1,382 @@ +# Methods for calculating coreness #### + +#' Methods for calculating coreness +#' @name method_coreness +#' @description +#' These functions calculate how core-like each node is, returning both a +#' continuous coreness score and a core/periphery split that +#' [node_is_core()], [node_by_coreness()] and [node_in_core()] then use. +#' +#' - `coreness_correlation()` fits the network to an ideal core-periphery +#' pattern by correlation. +#' - `coreness_richcore()` ranks nodes by strength and cuts where the tie +#' weight to higher-ranked neighbours peaks. +#' - `coreness_transition()` scores nodes with a transition function whose +#' sharpness and core size are free parameters. +#' - `coreness_hub()` scores nodes by how well they send to and receive from +#' the core, which lets core and periphery differ by tie direction. +#' +#' They differ in what they can use. `coreness_richcore()` and +#' `coreness_hub()` read tie direction and tie weights directly. +#' `coreness_correlation()` and `coreness_transition()` compare the network +#' against a symmetric ideal, so they symmetrise a directed network first +#' and report that they have done so. +#' @template param_data +#' @param direction One of "all" (the default), "out", or "in". +#' For a directed network, "out" scores nodes on the ties they send and +#' "in" on the ties they receive. +#' Ignored for undirected and two-mode networks. +#' @returns A list with two elements: +#' +#' - `coreness`: a numeric vector between 0 and 1, one value per node, +#' for how core-like each node is. +#' - `core`: a logical vector, one value per node, TRUE for the core. +#' +#' `coreness_hub()` adds `out_core` and `in_core`, the two core sets that a +#' directed core-periphery structure distinguishes. +#' @references +#' ## On the correlation method +#' Borgatti, Stephen P., and Martin G. Everett. 2000. +#' "Models of core/periphery structures". +#' _Social Networks_ 21(4): 375-395. +#' \doi{10.1016/S0378-8733(99)00019-2} +#' +#' Lip, Sean Z. W. 2011. +#' "A fast algorithm for the discrete core/periphery bipartitioning problem". +#' \doi{10.48550/arXiv.1102.5511} +#' +#' ## On the rich-core method +#' Ma, Athen, and Raul J. Mondragon. 2015. +#' "Rich-cores in networks". +#' _PLoS ONE_ 10(3): e0119678. +#' \doi{10.1371/journal.pone.0119678} +#' +#' ## On the transition method +#' Rombach, Puck, Mason A. Porter, James H. Fowler, and Peter J. Mucha. 2017. +#' "Core-periphery structure in networks (revisited)". +#' _SIAM Review_ 59(3): 619-646. +#' \doi{10.1137/17M1130046} +#' +#' ## On the hub method +#' Elliott, Andrew, Angus Chiu, Marya Bazzi, Gesine Reinert, +#' and Mihai Cucuringu. 2020. +#' "Core-periphery structure in directed networks". +#' _Proceedings of the Royal Society A_ 476(2241): 20190783. +#' \doi{10.1098/rspa.2019.0783} +#' @family methods +NULL + +# 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) + switch(direction, + out = mat, + `in` = t(mat), + all = mat + t(mat)) +} + +# The degree (or, for a weighted network, the strength) that goes with that +# matrix. For a one-mode network this is the row sum of the oriented matrix. +.core_strength <- function(mat, twomode = FALSE){ + if(twomode) c(rowSums(mat), colSums(mat)) else rowSums(mat) +} + +# Lip's (2011) cut. Ordering the nodes and adding them to the core one at a +# time, the quantity Z rises by `(k-1) - degi` at each step, so the whole +# sequence can be swept in one pass and the best cut kept. Any ordering may be +# passed: the degrees still measure Z exactly, so a coreness ordering is as +# valid here as the degree ordering Lip uses. +# +# `pairs` is 1 for a symmetric matrix, where the k nodes already in the core +# hold k(k-1)/2 pairs, and 2 for a directed one, where they hold k(k-1) +# ordered pairs and each is either sent or received. In the directed case +# `degi` must be the total of the in- and out-degrees. +.lip_cut <- function(degi, nord, pairs = 1){ + n <- length(degi) + if(n < 2) return(rep(TRUE, n)) + zbest <- Inf + kbest <- 0 + z <- sum(degi)/2 + for(k in seq_len(n-1)){ + z <- z + pairs*(k - 1) - degi[nord][k] + if(z < zbest){ + zbest <- z + kbest <- k + } + } + seq_len(n) %in% nord[seq_len(kbest)] +} + +# Scales a vector onto [0,1]. A constant vector has no gradient to report, +# so every node is given the same middling score rather than an NaN. +.core_scale <- function(x){ + rng <- range(x) + if(!is.finite(rng[1]) || diff(rng) == 0) return(rep(0.5, length(x))) + (x - rng[1])/diff(rng) +} + +# Says once, and only for a directed network, that a method cannot read +# direction and has symmetrised the network to proceed. +.core_symmetrise_info <- function(.data, method){ + if(manynet::is_directed(.data)) + manynet::snet_info("{.fn {method}} compares the network against a", + "symmetric ideal, so tie direction is not used.", + "For a directed core-periphery structure,", + "see {.fn coreness_hub}.") +} + +# Correlation #### + +#' @rdname method_coreness +#' @section Correlation: +#' Borgatti and Everett's continuous model gives each node a coreness +#' \eqn{c_i} between 0 and 1, and compares the network against the ideal +#' pattern \eqn{c_i c_j} in which two nodes are tied to the extent that both +#' are core: +#' \deqn{\rho = \text{cor}(A_{ij}, c_i c_j), i \neq j} +#' The coreness vector that maximises \eqn{\rho} is the fitted model. +#' Self-ties are excluded from the correlation, since no node is tied to +#' itself and including the diagonal pulls every coreness toward zero. +#' +#' The problem is not convex, so the search is run from several starting +#' points, ordered by degree, and the best fit is kept. +#' A weighted network is fitted to its weights, which means that the ideal +#' pattern is read as how _strongly_ two core nodes should be tied. +#' To fit the pattern of ties instead of their weights, +#' use [manynet::to_unweighted()] first. +#' +#' The search has one free value per node, so its cost grows quickly with +#' the size of the network. On a large network, lower `starts`, or use +#' [coreness_richcore()], which needs no search at all. +#' @param starts Integer number of starting points for the search. +#' By default 5. +#' @examples +#' coreness_correlation(ison_adolescents) +#' @export +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)) + manynet::snet_abort("{.fn coreness_correlation} compares the network", + "against a square ideal, which a two-mode network is", + "not. Try {.fn coreness_richcore} instead.") + .core_symmetrise_info(.data, "coreness_correlation") + mat <- .core_matrix(.data, "all") + n <- nrow(mat) + offdiag <- which(diag(n) == 0) + obs <- mat[offdiag] + obj_fun <- function(c){ + val <- suppressWarnings(stats::cor(obs, outer(c, c)[offdiag])) + if(!is.finite(val)) return(1e6) + -val + } + # Starting from the degree ordering rather than from a flat vector, which + # makes the ideal pattern constant and the correlation undefined. + degi <- .core_scale(rowSums(mat)) + inits <- lapply(seq_len(starts), function(i) + if(i == 1) degi else .core_scale(degi + stats::runif(n, -0.25, 0.25))) + fits <- lapply(inits, function(init) + stats::optim(init, obj_fun, method = "L-BFGS-B", lower = 0, upper = 1)) + best <- fits[[which.min(vapply(fits, function(f) f$value, numeric(1)))]] + out <- .core_scale(best$par) + list(coreness = out, + core = .lip_cut(rowSums(mat), order(out, decreasing = TRUE))) +} + +# Rich-core #### + +#' @rdname method_coreness +#' @section Rich-core: +#' Ma and Mondragon rank the nodes by strength, from strongest to weakest, +#' and give each node the total weight of its ties to nodes that rank above +#' it: +#' \deqn{\sigma_i^+ = \sum_{j : r_j < r_i} w_{ij}} +#' Walking down the ranking, \eqn{\sigma^+} rises while the nodes added are +#' still tied to those already above them, and falls once they are not. +#' The rank at which it peaks is the boundary of the rich core. +#' +#' The method needs no parameters and no optimisation, and it reads tie +#' weights and tie direction directly, which makes it the method this +#' package uses by default for a weighted, directed, or two-mode network. +#' For a two-mode network the nodes of both modes are ranked together, so +#' the core may span both. +#' +#' Note that the core it finds is one whose members are tied to _each other_. +#' Where a directed network instead has one set that sends and a different +#' set that receives, \eqn{\sigma^+} never rises, and the method returns a +#' core of one or two nodes. Use [coreness_hub()] for that structure, which +#' keeps the two sets apart rather than trying to merge them. +#' @examples +#' coreness_richcore(ison_networkers) +#' @export +coreness_richcore <- function(.data, direction = c("all","out","in")){ + .data <- manynet::expect_nodes(.data) + direction <- match.arg(direction) + twomode <- manynet::is_twomode(.data) + mat <- .core_matrix(.data, direction) + stren <- .core_strength(mat, twomode) + n <- length(stren) + # A square matrix over all nodes, so that a two-mode network can be walked + # in the same way as a one-mode one. + full <- if(twomode){ + sq <- matrix(0, n, n) + sq[seq_len(nrow(mat)), nrow(mat) + seq_len(ncol(mat))] <- mat + sq + t(sq) + } else mat + nord <- order(stren, decreasing = TRUE) + # The weight each node sends to those ranked above it. + sigma <- vapply(seq_len(n), function(k){ + if(k == 1) return(0) + sum(full[nord[k], nord[seq_len(k-1)]]) + }, numeric(1)) + kbest <- which.max(sigma) + list(coreness = .core_scale(stren), + core = seq_len(n) %in% nord[seq_len(kbest)]) +} + +# Transition #### + +#' @rdname method_coreness +#' @section Transition: +#' Rombach and colleagues score the node at rank \eqn{m} with a transition +#' function +#' \deqn{C_m = \frac{1}{1 + \exp(-(m - N\beta)\tan(\pi\alpha/2))}} +#' where \eqn{\alpha} sets how sharp the boundary between core and periphery +#' is, from fuzziest at 0 to a clean step at 1, and \eqn{\beta} sets how +#' large the core is, from every node at 0 to none at 1. +#' The ordering that maximises the core quality +#' \eqn{R = \sum_{ij} A_{ij} C_i C_j} is the fitted model. +#' +#' No single \eqn{\alpha} and \eqn{\beta} is right for every network, so the +#' score is aggregated over a grid of both, weighting each by the core +#' quality it achieves, and scaled so that the most core-like node is 1. +#' @param alpha Numeric vector of boundary sharpness values between 0 and 1, +#' to aggregate over. By default `seq(0.2, 0.8, 0.2)`. +#' @param beta Numeric vector of core size values between 0 and 1, +#' to aggregate over. By default `seq(0.2, 0.8, 0.2)`. +#' @examples +#' coreness_transition(ison_adolescents) +#' @export +coreness_transition <- function(.data, direction = c("all","out","in"), + alpha = seq(0.2, 0.8, 0.2), + beta = seq(0.2, 0.8, 0.2)){ + .data <- manynet::expect_nodes(.data) + direction <- match.arg(direction) + if(manynet::is_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_richcore} instead.") + .core_symmetrise_info(.data, "coreness_transition") + mat <- .core_matrix(.data, "all") + n <- nrow(mat) + total <- rep(0, n) + for(a in alpha) for(b in beta){ + cstar <- .transition_values(n, a, b) + nord <- .transition_order(mat, cstar) + cvec <- numeric(n) + cvec[nord] <- cstar + quality <- sum(mat * outer(cvec, cvec)) + total <- total + cvec*quality + } + out <- .core_scale(total) + list(coreness = out, + core = .lip_cut(rowSums(mat), order(out, decreasing = TRUE))) +} + +# The transition function itself, ascending, so that position `n` is the most +# core-like. `alpha` of 1 would make the tangent infinite, so it is held just +# below, which is a step function to any precision that matters here. +.transition_values <- function(n, alpha, beta){ + m <- seq_len(n) + 1/(1 + exp(-(m - n*beta)*tan(pi*min(alpha, 0.999)/2))) +} + +# Finding the ordering that maximises the core quality is a search over +# permutations. Starting from the degree ordering, which is already a good +# guess, pairs are swapped whenever a swap improves the quality, and the +# sweeps stop as soon as one passes without an improvement. +# +# The quality is never recomputed from scratch. Since the matrix is symmetric, +# writing the quality as c'Ac and a swap as c + e(1_u - 1_v) gives +# dR = 2e((Ac)_u - (Ac)_v) + e^2(A_uu - 2A_uv + A_vv) +# so each candidate costs a constant amount, and only an accepted swap costs +# the linear update of Ac. Without this the search is quartic in the number of +# nodes, and it is run once for every pair of parameters. +.transition_order <- function(mat, cstar, sweeps = 10L){ + n <- nrow(mat) + nord <- order(rowSums(mat)) + cvec <- numeric(n) + cvec[nord] <- cstar + ac <- as.vector(mat %*% cvec) + for(s in seq_len(sweeps)){ + improved <- FALSE + for(i in seq_len(n-1)) for(j in seq(i+1, n)){ + u <- nord[i]; v <- nord[j] + e <- cstar[j] - cstar[i] + if(e == 0) next + delta <- 2*e*(ac[u] - ac[v]) + e*e*(mat[u,u] - 2*mat[u,v] + mat[v,v]) + if(delta > 0){ + nord[c(i,j)] <- nord[c(j,i)] + cvec[u] <- cvec[u] + e + cvec[v] <- cvec[v] - e + ac <- ac + e*(mat[,u] - mat[,v]) + improved <- TRUE + } + } + if(!improved) break + } + nord +} + +# Hub #### + +#' @rdname method_coreness +#' @section Hub: +#' In a directed network a node can be core in whom it reaches and +#' peripheral in who reaches it. Elliott and colleagues therefore keep two +#' core sets rather than one: an out-core of nodes that send to the core, +#' and an in-core of nodes that receive from it. +#' +#' The two are read from the hub and authority scores that +#' [node_by_hub()] and [node_by_authority()] already provide: a hub is a +#' node that points to good authorities, and an authority is a node that +#' good hubs point to, which is the same mutual definition the two core sets +#' have. Each set is then cut by the same rule the other methods use. +#' With `direction = "all"` the returned coreness is the geometric mean of +#' the two scores, and the core is the set of nodes in both. +#' @examples +#' coreness_hub(ison_networkers) +#' @export +coreness_hub <- function(.data, direction = c("all","out","in")){ + .data <- manynet::expect_nodes(.data) + direction <- match.arg(direction) + if(!manynet::is_directed(.data)) + manynet::snet_info("{.fn coreness_hub} distinguishes an out-core from an", + "in-core, which an undirected network does not,", + "so the two are the same here.") + hub <- .core_scale(as.numeric(node_by_hub(.data))) + auth <- .core_scale(as.numeric(node_by_authority(.data))) + # Both cores are cut against the same directed block structure. What + # separates them is the ordering: the out-core is swept in hub order, the + # in-core in authority order. + mat <- manynet::as_matrix(.data) + degi <- rowSums(mat) + colSums(mat) + directed <- if(manynet::is_directed(.data)) 2 else 1 + out_core <- .lip_cut(degi, order(hub, decreasing = TRUE), directed) + in_core <- .lip_cut(degi, order(auth, decreasing = TRUE), directed) + coreness <- switch(direction, + out = hub, + `in` = auth, + all = .core_scale(sqrt(hub*auth))) + core <- switch(direction, + out = out_core, + `in` = in_core, + all = out_core & in_core) + list(coreness = coreness, core = core, + out_core = out_core, in_core = in_core) +} diff --git a/man-roxygen/param_coreness.R b/man-roxygen/param_coreness.R new file mode 100644 index 0000000..11cb29e --- /dev/null +++ b/man-roxygen/param_coreness.R @@ -0,0 +1,10 @@ +#' @param coreness Which method to use to calculate nodes' coreness. +#' One of "correlation", "richcore", "transition", or "hub"; +#' see [method_coreness] for what each does. +#' By default NULL, which uses "richcore" for a weighted, directed, or +#' two-mode network, since it is the only method that reads those properties +#' directly, and "correlation" otherwise. +#' @param direction One of "all" (the default), "out", or "in". +#' For a directed network, "out" scores nodes on the ties they send and +#' "in" on the ties they receive. +#' Ignored for undirected and two-mode networks. diff --git a/man/method_coreness.Rd b/man/method_coreness.Rd new file mode 100644 index 0000000..2aebe1d --- /dev/null +++ b/man/method_coreness.Rd @@ -0,0 +1,201 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/method_coreness.R +\name{method_coreness} +\alias{method_coreness} +\alias{coreness_correlation} +\alias{coreness_richcore} +\alias{coreness_transition} +\alias{coreness_hub} +\title{Methods for calculating coreness} +\usage{ +coreness_correlation(.data, direction = c("all", "out", "in"), starts = 5L) + +coreness_richcore(.data, direction = c("all", "out", "in")) + +coreness_transition( + .data, + direction = c("all", "out", "in"), + alpha = seq(0.2, 0.8, 0.2), + beta = seq(0.2, 0.8, 0.2) +) + +coreness_hub(.data, direction = c("all", "out", "in")) +} +\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{direction}{One of "all" (the default), "out", or "in". +For a directed network, "out" scores nodes on the ties they send and +"in" on the ties they receive. +Ignored for undirected and two-mode networks.} + +\item{starts}{Integer number of starting points for the search. +By default 5.} + +\item{alpha}{Numeric vector of boundary sharpness values between 0 and 1, +to aggregate over. By default \code{seq(0.2, 0.8, 0.2)}.} + +\item{beta}{Numeric vector of core size values between 0 and 1, +to aggregate over. By default \code{seq(0.2, 0.8, 0.2)}.} +} +\value{ +A list with two elements: +\itemize{ +\item \code{coreness}: a numeric vector between 0 and 1, one value per node, +for how core-like each node is. +\item \code{core}: a logical vector, one value per node, TRUE for the core. +} + +\code{coreness_hub()} adds \code{out_core} and \code{in_core}, the two core sets that a +directed core-periphery structure distinguishes. +} +\description{ +These functions calculate how core-like each node is, returning both a +continuous coreness score and a core/periphery split that +\code{\link[=node_is_core]{node_is_core()}}, \code{\link[=node_by_coreness]{node_by_coreness()}} and \code{\link[=node_in_core]{node_in_core()}} then use. +\itemize{ +\item \code{coreness_correlation()} fits the network to an ideal core-periphery +pattern by correlation. +\item \code{coreness_richcore()} ranks nodes by strength and cuts where the tie +weight to higher-ranked neighbours peaks. +\item \code{coreness_transition()} scores nodes with a transition function whose +sharpness and core size are free parameters. +\item \code{coreness_hub()} scores nodes by how well they send to and receive from +the core, which lets core and periphery differ by tie direction. +} + +They differ in what they can use. \code{coreness_richcore()} and +\code{coreness_hub()} read tie direction and tie weights directly. +\code{coreness_correlation()} and \code{coreness_transition()} compare the network +against a symmetric ideal, so they symmetrise a directed network first +and report that they have done so. +} +\section{Correlation}{ + +Borgatti and Everett's continuous model gives each node a coreness +\eqn{c_i} between 0 and 1, and compares the network against the ideal +pattern \eqn{c_i c_j} in which two nodes are tied to the extent that both +are core: +\deqn{\rho = \text{cor}(A_{ij}, c_i c_j), i \neq j} +The coreness vector that maximises \eqn{\rho} is the fitted model. +Self-ties are excluded from the correlation, since no node is tied to +itself and including the diagonal pulls every coreness toward zero. + +The problem is not convex, so the search is run from several starting +points, ordered by degree, and the best fit is kept. +A weighted network is fitted to its weights, which means that the ideal +pattern is read as how \emph{strongly} two core nodes should be tied. +To fit the pattern of ties instead of their weights, +use \code{\link[manynet:to_unweighted]{manynet::to_unweighted()}} first. + +The search has one free value per node, so its cost grows quickly with +the size of the network. On a large network, lower \code{starts}, or use +\code{\link[=coreness_richcore]{coreness_richcore()}}, which needs no search at all. +} + +\section{Rich-core}{ + +Ma and Mondragon rank the nodes by strength, from strongest to weakest, +and give each node the total weight of its ties to nodes that rank above +it: +\deqn{\sigma_i^+ = \sum_{j : r_j < r_i} w_{ij}} +Walking down the ranking, \eqn{\sigma^+} rises while the nodes added are +still tied to those already above them, and falls once they are not. +The rank at which it peaks is the boundary of the rich core. + +The method needs no parameters and no optimisation, and it reads tie +weights and tie direction directly, which makes it the method this +package uses by default for a weighted, directed, or two-mode network. +For a two-mode network the nodes of both modes are ranked together, so +the core may span both. + +Note that the core it finds is one whose members are tied to \emph{each other}. +Where a directed network instead has one set that sends and a different +set that receives, \eqn{\sigma^+} never rises, and the method returns a +core of one or two nodes. Use \code{\link[=coreness_hub]{coreness_hub()}} for that structure, which +keeps the two sets apart rather than trying to merge them. +} + +\section{Transition}{ + +Rombach and colleagues score the node at rank \eqn{m} with a transition +function +\deqn{C_m = \frac{1}{1 + \exp(-(m - N\beta)\tan(\pi\alpha/2))}} +where \eqn{\alpha} sets how sharp the boundary between core and periphery +is, from fuzziest at 0 to a clean step at 1, and \eqn{\beta} sets how +large the core is, from every node at 0 to none at 1. +The ordering that maximises the core quality +\eqn{R = \sum_{ij} A_{ij} C_i C_j} is the fitted model. + +No single \eqn{\alpha} and \eqn{\beta} is right for every network, so the +score is aggregated over a grid of both, weighting each by the core +quality it achieves, and scaled so that the most core-like node is 1. +} + +\section{Hub}{ + +In a directed network a node can be core in whom it reaches and +peripheral in who reaches it. Elliott and colleagues therefore keep two +core sets rather than one: an out-core of nodes that send to the core, +and an in-core of nodes that receive from it. + +The two are read from the hub and authority scores that +\code{\link[=node_by_hub]{node_by_hub()}} and \code{\link[=node_by_authority]{node_by_authority()}} already provide: a hub is a +node that points to good authorities, and an authority is a node that +good hubs point to, which is the same mutual definition the two core sets +have. Each set is then cut by the same rule the other methods use. +With \code{direction = "all"} the returned coreness is the geometric mean of +the two scores, and the core is the set of nodes in both. +} + +\examples{ +coreness_correlation(ison_adolescents) +coreness_richcore(ison_networkers) +coreness_transition(ison_adolescents) +coreness_hub(ison_networkers) +} +\references{ +\subsection{On the correlation method}{ + +Borgatti, Stephen P., and Martin G. Everett. 2000. +"Models of core/periphery structures". +\emph{Social Networks} 21(4): 375-395. +\doi{10.1016/S0378-8733(99)00019-2} + +Lip, Sean Z. W. 2011. +"A fast algorithm for the discrete core/periphery bipartitioning problem". +\doi{10.48550/arXiv.1102.5511} +} + +\subsection{On the rich-core method}{ + +Ma, Athen, and Raul J. Mondragon. 2015. +"Rich-cores in networks". +\emph{PLoS ONE} 10(3): e0119678. +\doi{10.1371/journal.pone.0119678} +} + +\subsection{On the transition method}{ + +Rombach, Puck, Mason A. Porter, James H. Fowler, and Peter J. Mucha. 2017. +"Core-periphery structure in networks (revisited)". +\emph{SIAM Review} 59(3): 619-646. +\doi{10.1137/17M1130046} +} + +\subsection{On the hub method}{ + +Elliott, Andrew, Angus Chiu, Marya Bazzi, Gesine Reinert, +and Mihai Cucuringu. 2020. +"Core-periphery structure in directed networks". +\emph{Proceedings of the Royal Society A} 476(2241): 20190783. +\doi{10.1098/rspa.2019.0783} +} +} +\seealso{ +Other methods: +\code{\link{method_regularity}} +} +\concept{methods} From 515513087120f2d4158528b8cedec10e6b0ced8e Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 18:29:24 +0200 Subject: [PATCH 44/68] Renamed `node_by_coreness()` to `node_by_core()` --- NAMESPACE | 1 + NEWS.md | 4 ++ R/class_metrics.R | 43 ++++++++++++++++++++ R/measure_features.R | 45 ++++++++++++++++----- R/member_core.R | 67 ++++++++++++++++++------------- R/netrics-defunct.R | 14 +++++++ man/measure_core.Rd | 42 +++++++++++++++++-- man/method_regularity.Rd | 4 ++ tests/testthat/helper-contract.R | 17 ++++++-- tests/testthat/test-measure_fit.R | 2 +- 10 files changed, 192 insertions(+), 47 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 27e7079..65304ff 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -91,6 +91,7 @@ export(node_by_brokering_activity) export(node_by_brokering_exclusivity) export(node_by_closeness) export(node_by_constraint) +export(node_by_core) export(node_by_coreness) export(node_by_decay) export(node_by_deg) diff --git a/NEWS.md b/NEWS.md index fdf2804..af920b0 100644 --- a/NEWS.md +++ b/NEWS.md @@ -96,6 +96,10 @@ - `node_in_community()` considers only these algorithms when `k` is given - `k` also accepts `"silhouette"`, `"elbow"`, and `"strict"`, as in `node_in_equivalence()` - Note `k=` is now the second argument, so positional calls such as `node_in_louvain(x, 0.5)` must become `node_in_louvain(x, resolution = 0.5)` +- Renamed `node_by_coreness()` to `node_by_core()` + - Fixed search starting points rather than random + - Fixed it returning identical scores for a directed network and its reverse + - Fixed it erroring on two-mode networks whose modes are of unequal size - Added `node_in_labels()` for label propagation community detection - Fixed `node_in_community()` returning nothing but an error whenever verbosity was not `"verbose"` - Renamed `times=` in `node_in_walktrap()` to `steps=`, which is more descriptive and consistent with `{igraph}` diff --git a/R/class_metrics.R b/R/class_metrics.R index f8f46f6..91f0bef 100644 --- a/R/class_metrics.R +++ b/R/class_metrics.R @@ -167,3 +167,46 @@ make_network_motif <- function(out, .data) { attr(out, "call") <- deparse(sys.calls()) out } + +# Coreness methods #### + +# The core-periphery family used to name its methods after centralities, +# which only made sense while every method ranked nodes by one. Accepts the +# old spelling and warns, as `resolve_scaled()` does. +resolve_coreness <- function(coreness, centrality = NULL) { + if(!is.null(centrality)) { + warning("The `centrality` argument has been replaced by `coreness`, ", + "which names the method rather than the ranking it happens to ", + "use. Please use `coreness` instead.", call. = FALSE) + if(is.null(coreness)) coreness <- "correlation" + } + coreness +} + +CORENESSES <- c("correlation", "richcore", "transition", "hub") + +# Chooses the method when the user has not, and says which it chose. No one +# method suits every network: the correlation and transition methods compare +# the network against a square, symmetric ideal, so they can neither read tie +# direction nor run on a two-mode network, while the rich-core method reads +# both weights and direction directly. So the choice follows the network. +check_coreness <- function(.data, coreness = NULL) { + if(is.null(coreness)) { + coreness <- if(manynet::is_twomode(.data) || + manynet::is_weighted(.data) || + manynet::is_directed(.data)) "richcore" else "correlation" + manynet::snet_info("Calculating coreness using", + "{.fn coreness_{coreness}}.") + } else coreness <- match.arg(coreness, CORENESSES) + coreness +} + +# Runs the chosen method. Kept in one place so that the mark, the measure and +# the membership cannot drift apart in what they dispatch on. +run_coreness <- function(.data, coreness, direction = "all") { + switch(coreness, + correlation = coreness_correlation(.data, direction = direction), + richcore = coreness_richcore(.data, direction = direction), + transition = coreness_transition(.data, direction = direction), + hub = coreness_hub(.data, direction = direction)) +} diff --git a/R/measure_features.R b/R/measure_features.R index 5529013..014bd10 100644 --- a/R/measure_features.R +++ b/R/measure_features.R @@ -396,11 +396,17 @@ NULL #' member of the core and the most core-like member of the periphery. #' "diff" is similar to "ndiff", but multiplies the raw "ndiff" score by the #' square root of the size of the core, thus penalising large cores. +#' @template param_coreness #' @section Core-Periphery: -#' `net_core()` calculates the Pearson correlation between the given network, -#' where the nodes in the core are assigned by some given mark, and an ideal -#' typical core-periphery network with the same number of nodes in the core -#' and the periphery. +#' `net_by_core()` calculates the Pearson correlation between the given +#' network, where the nodes in the core are assigned by some given mark, and +#' an ideal typical core-periphery network with the same number of nodes in +#' the core and the periphery. +#' +#' Where `mark` is not given, it is calculated with [node_is_core()], to +#' which the `coreness` and `direction` arguments are passed. For a directed +#' network the fit itself is measured on the symmetrised network, since the +#' ideal it is compared against is symmetric. #' @references #' ## On core-periphery #' Borgatti, Stephen P., and Martin G. Everett. 2000. @@ -413,20 +419,37 @@ NULL #' @export net_by_core <- function(.data, mark = NULL, - method = c("correlation","ident","ndiff", "diff")){ + method = c("correlation","ident","ndiff", "diff"), + coreness = NULL, + direction = c("all","out","in")){ .data <- manynet::expect_nodes(.data) - if(is.null(mark)) mark <- node_is_core(.data) + direction <- match.arg(direction) + if(is.null(mark)) mark <- node_is_core(.data, coreness = coreness, + direction = direction) method <- match.arg(method) + # `manynet::create_core()` returns an upper-triangular matrix for a directed + # network rather than a directed core-periphery ideal, so comparing a + # directed network against it would compare unlike with unlike. Both sides + # are therefore symmetrised, and the user is told that direction is not read + # here even where the assignment in `mark` read it. + obs <- manynet::as_matrix(.data) + ideal <- manynet::as_matrix(manynet::create_core(.data, mark = mark)) + if(manynet::is_directed(.data)){ + manynet::snet_info("{.fn net_by_core} compares the network against a", + "symmetric ideal, so tie direction is not used in the", + "fit itself.") + obs <- pmax(obs, t(obs)) + ideal <- pmax(ideal, t(ideal)) + } if(method == "correlation"){ - out <- stats::cor(c(manynet::as_matrix(.data)), - c(manynet::as_matrix(manynet::create_core(.data, mark = mark)))) + out <- stats::cor(c(obs), c(ideal)) } else if(method == "ident"){ - out <- sqrt(sum((manynet::as_matrix(.data) - - manynet::as_matrix(manynet::create_core(.data, mark = mark)))^2)) + out <- sqrt(sum((obs - ideal)^2)) } else if(method %in% c("ndiff","diff")){ # Sort nodes by coreness - c_scores <- node_by_coreness(.data) + c_scores <- node_by_core(.data, coreness = coreness, + direction = direction) core <- c_scores[mark] periphery <- c_scores[!mark] diff --git a/R/member_core.R b/R/member_core.R index dd6b01b..45af51f 100644 --- a/R/member_core.R +++ b/R/member_core.R @@ -9,12 +9,8 @@ #' @template param_data #' @family core-periphery #' @template node_mark -#' @param centrality Which centrality measure to use to identify cores and periphery. -#' By default this is "degree", -#' which relies on the heuristic that high degree nodes are more likely to be in the core. -#' An alternative is "eigenvector", which instead begins with high eigenvector nodes. -#' Other methods, such as a genetic algorithm, CONCOR, and Rombach-Porter, -#' can be added if there is interest. +#' @template param_coreness +#' @param centrality Deprecated; use `coreness` instead. NULL #' @rdname mark_core @@ -23,21 +19,24 @@ NULL #' and which to the periphery. #' It seeks to minimize the following quantity: #' \deqn{Z(S_1) = \sum_{(i Date: Thu, 27 Aug 2026 18:30:00 +0200 Subject: [PATCH 45/68] Improved `node_in_core()` --- NEWS.md | 6 ++ R/member_core.R | 151 +++++++++++++++++++----------- R/method_coreness.R | 27 +++++- man/defunct.Rd | 29 ++++++ man/mark_core.Rd | 39 +++++--- man/measure_fit.Rd | 29 +++++- man/member_core.Rd | 48 +++++++++- man/method_coreness.Rd | 8 +- tests/testthat/test-member_core.R | 113 +++++++++++++++++++++- 9 files changed, 369 insertions(+), 81 deletions(-) diff --git a/NEWS.md b/NEWS.md index af920b0..4865a68 100644 --- a/NEWS.md +++ b/NEWS.md @@ -100,6 +100,12 @@ - Fixed search starting points rather than random - Fixed it returning identical scores for a directed network and its reverse - Fixed it erroring on two-mode networks whose modes are of unequal size +- Improved `node_in_core()` + - Renamed `centrality=` to `coreness=`: `"richcore"` default for weighted, directed, or two-mode networks, + `"correlation"` otherwise + - Adds `direction=` for directed networks, adding `"Sender"` for core out-ties and periphery in-ties and + `"Receiver"` for core in-ties and periphery out-ties + - Fixed sorting numbered middle labels alphabetically or from arbitrary cluster numbers - Added `node_in_labels()` for label propagation community detection - Fixed `node_in_community()` returning nothing but an error whenever verbosity was not `"verbose"` - Renamed `times=` in `node_in_walktrap()` to `steps=`, which is more descriptive and consistent with `{igraph}` diff --git a/R/member_core.R b/R/member_core.R index 45af51f..d433f8e 100644 --- a/R/member_core.R +++ b/R/member_core.R @@ -43,30 +43,14 @@ NULL #' ison_adolescents |> #' mutate(corep = node_is_core()) #' @export -node_is_core <- function(.data, centrality = c("degree", "eigenvector")){ +node_is_core <- function(.data, coreness = NULL, + direction = c("all","out","in"), + centrality = NULL){ .data <- manynet::expect_nodes(.data) - centrality <- match.arg(centrality) - if(manynet::is_directed(.data)) warning("Asymmetric core-periphery not yet implemented.") - if(centrality == "degree"){ - degi <- node_by_degree(.data, normalized = FALSE, - alpha = ifelse(manynet::is_weighted(.data), 1, 0)) - } else if (centrality == "eigenvector") { - degi <- node_by_eigenvector(.data, normalized = FALSE) - } else manynet::snet_abort("This function expects either 'degree' or 'eigenvector' method to be specified.") - nord <- order(degi, decreasing = TRUE) - zbest <- manynet::net_nodes(.data)*3 - kbest <- 0 - z <- 1/2*sum(degi) - for(k in 1:(manynet::net_nodes(.data)-1)){ - z <- z + k - 1 - degi[nord][k] - if(z < zbest){ - zbest <- z - kbest <- k - } - } - out <- ifelse(seq_len(manynet::net_nodes(.data)) %in% nord[seq_len(kbest)], - 1,2) - make_node_mark(out==1, .data) + direction <- match.arg(direction) + coreness <- check_coreness(.data, resolve_coreness(coreness, centrality)) + out <- run_coreness(.data, coreness, direction) + make_node_mark(out$core, .data) } # Measuring core #### @@ -167,6 +151,16 @@ NULL #' @param cluster_by Method to use to create the categories. #' One of "bins" (equal-width bins), "quantiles" (quantile-based bins), #' or "kmeans" (k-means clustering). Default is "bins". +#' @param coreness Which method to use to calculate nodes' coreness. +#' One of "correlation", "richcore", "transition", or "hub"; +#' see [method_coreness] for what each does. +#' By default NULL, which uses "richcore" for a weighted, directed, or +#' two-mode network, since it is the only method that reads those properties +#' directly, and "correlation" otherwise. +#' @param direction One of "all" (the default), "out", "in", or "both". +#' For a directed network, "out" scores nodes on the ties they send and +#' "in" on the ties they receive, while "both" returns the four categories +#' described below. Ignored for undirected and two-mode networks. #' @section Core-periphery categories: #' This function categorizes nodes based on their coreness into a specified #' number of groups. The groups are labeled as "Core", "Semi-core", @@ -174,50 +168,101 @@ NULL #' specified. #' The categorization can be done using different methods: equal-width bins, #' quantile-based bins, or k-means clustering. +#' @section Directed core-periphery: +#' In a directed network a node can be core in whom it reaches and +#' peripheral in who reaches it, which one core and one periphery cannot +#' express. `direction = "both"` therefore returns the four categories that +#' Elliott and colleagues distinguish: +#' +#' - "Core" for nodes in both the out-core and the in-core, +#' - "Sender" for nodes in the out-core only, +#' - "Receiver" for nodes in the in-core only, +#' - "Periphery" for nodes in neither. +#' +#' This uses [coreness_hub()], so `groups` and `cluster_by` do not apply. #' @references #' ## On core-periphery categorization #' Wallerstein, Immanuel. 1974. #' "Dependence in an Interdependent World: The Limited Possibilities of Transformation Within the Capitalist World Economy." #' _African Studies Review_, 17(1), 1-26. -#' \doi{https://doi.org/10.2307/523574} +#' \doi{10.2307/523574} +#' +#' ## On directed core-periphery +#' Elliott, Andrew, Angus Chiu, Marya Bazzi, Gesine Reinert, +#' and Mihai Cucuringu. 2020. +#' "Core-periphery structure in directed networks". +#' _Proceedings of the Royal Society A_ 476(2241): 20190783. +#' \doi{10.1098/rspa.2019.0783} #' @examples #' node_in_core(ison_adolescents) +#' node_in_core(ison_networkers, direction = "both") #' @export node_in_core <- function(.data, groups = 3, - cluster_by = c("bins","quantiles","kmeans")) { + cluster_by = c("bins","quantiles","kmeans"), + coreness = NULL, + direction = c("all","out","in","both")) { + .data <- manynet::expect_nodes(.data) + direction <- match.arg(direction) + if(direction == "both") return(.core_four_sets(.data)) if (groups < 2) manynet::snet_abort("Number of categories must be at least 2") if (groups > manynet::net_nodes(.data)) manynet::snet_abort("There cannot be more categories than nodes.") - .data <- manynet::expect_nodes(.data) - contin <- node_by_coreness(.data) + contin <- as.numeric(node_by_core(.data, coreness = coreness, + direction = direction)) cluster_by <- match.arg(cluster_by) out <- switch(cluster_by, - bins = cut(as.numeric(contin), breaks = groups, labels = FALSE), - quantiles = as.numeric(cut(as.numeric(contin), - breaks = stats::quantile(as.numeric(contin), + bins = cut(contin, breaks = groups, labels = FALSE), + quantiles = as.numeric(cut(contin, + breaks = stats::quantile(contin, probs = seq(0, 1, length.out = groups + 1)), include.lowest = TRUE, labels = FALSE)), - kmeans = stats::kmeans(as.numeric(contin), centers = groups)$cluster + # k-means numbers its clusters in whatever order it finds + # them, so the numbers must be put back in coreness order + # before they can index the labels. + kmeans = { + km <- stats::kmeans(contin, centers = groups) + order(order(km$centers))[km$cluster] + } ) - - if (groups == 2) core_labels <- c("Core", "Periphery") - if (groups == 3) core_labels <- c("Core", "Semi-periphery", "Periphery") - if (groups == 4) core_labels <- c("Core", "Semi-core", "Semi-periphery", "Periphery") - if (groups >= 5){ - n_middle <- groups - 2 - middle <- character(n_middle) - - for (i in seq_len(n_middle)) { - if (i %% 2 == 1) { - middle[i] <- paste0("Semi-periphery-", (i + 1) %/% 2) - } else { - middle[i] <- paste0("Semi-core-", i %/% 2) - } - } - middle <- middle[order(middle)] - - core_labels <- c("Core", middle, "Periphery") - if(groups == 5) core_labels[2] <- "Semi-core" - } - out <- rev(core_labels)[out] + out <- rev(core_labels(groups))[out] make_node_member(out, .data) -} \ No newline at end of file +} + +# The four sets of a directed core-periphery structure, from the two cores +# that `coreness_hub()` distinguishes. +.core_four_sets <- function(.data){ + if(!manynet::is_directed(.data)) + manynet::snet_abort("{.arg direction = \"both\"} distinguishes an", + "out-core from an in-core, which an undirected", + "network does not.") + hubs <- coreness_hub(.data, direction = "all") + out <- ifelse(hubs$out_core & hubs$in_core, "Core", + ifelse(hubs$out_core, "Sender", + ifelse(hubs$in_core, "Receiver", "Periphery"))) + make_node_member(out, .data) +} + +# The labels, from most to least core. Beyond four groups the middle labels +# are numbered, alternating outwards from the core, and sorted by that number +# rather than by their spelling, which would put "Semi-core-10" before +# "Semi-core-2". +core_labels <- function(groups){ + if (groups == 2) return(c("Core", "Periphery")) + if (groups == 3) return(c("Core", "Semi-periphery", "Periphery")) + if (groups == 4) return(c("Core", "Semi-core", "Semi-periphery", "Periphery")) + n_middle <- groups - 2 + middle <- character(n_middle) + rank <- numeric(n_middle) + for (i in seq_len(n_middle)) { + if (i %% 2 == 1) { + middle[i] <- paste0("Semi-periphery-", (i + 1) %/% 2) + rank[i] <- n_middle + 1 - (i + 1) %/% 2 + } else { + middle[i] <- paste0("Semi-core-", i %/% 2) + rank[i] <- i %/% 2 + } + } + middle <- middle[order(rank)] + out <- c("Core", middle, "Periphery") + if(groups == 5) out[2] <- "Semi-core" + out +} diff --git a/R/method_coreness.R b/R/method_coreness.R index 425e70b..42ef974 100644 --- a/R/method_coreness.R +++ b/R/method_coreness.R @@ -5,7 +5,7 @@ #' @description #' These functions calculate how core-like each node is, returning both a #' continuous coreness score and a core/periphery split that -#' [node_is_core()], [node_by_coreness()] and [node_in_core()] then use. +#' [node_is_core()], [node_by_core()] and [node_in_core()] then use. #' #' - `coreness_correlation()` fits the network to an ideal core-periphery #' pattern by correlation. @@ -111,6 +111,22 @@ NULL seq_len(n) %in% nord[seq_len(kbest)] } +# The starting points for a restarted search. Raising the scaled degree to a +# ladder of powers sharpens or flattens it, which moves the start toward a +# smaller or a larger core, and the rank vector drops degree magnitude +# altogether. These explore different basins of the objective without any +# randomness, so that two calls on one network return the same answer: a +# descriptive measure that moved between calls would not be much use. +.core_inits <- function(degi, starts){ + powers <- c(1, 0.5, 2, 0.25, 4, 0.125, 8, 16) + cands <- c(lapply(powers, function(p) .core_scale(degi^p)), + list(.core_scale(rank(degi)))) + if(starts > length(cands)) + manynet::snet_info("At most {length(cands)} starting points are defined,", + "so {.arg starts} is capped there.") + cands[seq_len(min(starts, length(cands)))] +} + # Scales a vector onto [0,1]. A constant vector has no gradient to report, # so every node is given the same middling score rather than an NaN. .core_scale <- function(x){ @@ -152,8 +168,10 @@ NULL #' The search has one free value per node, so its cost grows quickly with #' the size of the network. On a large network, lower `starts`, or use #' [coreness_richcore()], which needs no search at all. -#' @param starts Integer number of starting points for the search. -#' By default 5. +#' @param starts Integer number of starting points for the search, +#' at most 9. By default 5. +#' The starting points are fixed rather than random, so that two calls on +#' the same network return the same answer. #' @examples #' coreness_correlation(ison_adolescents) #' @export @@ -178,8 +196,7 @@ coreness_correlation <- function(.data, direction = c("all","out","in"), # Starting from the degree ordering rather than from a flat vector, which # makes the ideal pattern constant and the correlation undefined. degi <- .core_scale(rowSums(mat)) - inits <- lapply(seq_len(starts), function(i) - if(i == 1) degi else .core_scale(degi + stats::runif(n, -0.25, 0.25))) + inits <- .core_inits(degi, starts) fits <- lapply(inits, function(init) stats::optim(init, obj_fun, method = "L-BFGS-B", lower = 0, upper = 1)) best <- fits[[which.min(vapply(fits, function(f) f$value, numeric(1)))]] diff --git a/man/defunct.Rd b/man/defunct.Rd index 7add876..cbda4fb 100644 --- a/man/defunct.Rd +++ b/man/defunct.Rd @@ -2,7 +2,28 @@ % Please edit documentation in R/netrics-defunct.R \name{defunct} \alias{defunct} +\alias{node_by_coreness} \title{Functions that have been renamed, superseded, or are no longer working} +\usage{ +node_by_coreness(.data, coreness = NULL, direction = c("all", "out", "in")) +} +\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{coreness}{Which method to use to calculate nodes' coreness. +One of "correlation", "richcore", "transition", or "hub"; +see \link{method_coreness} for what each does. +By default NULL, which uses "richcore" for a weighted, directed, or +two-mode network, since it is the only method that reads those properties +directly, and "correlation" otherwise.} + +\item{direction}{One of "all" (the default), "out", or "in". +For a directed network, "out" scores nodes on the ties they send and +"in" on the ties they receive. +Ignored for undirected and two-mode networks.} +} \value{ Results as expected along with a warning to use new function naming in the future. @@ -16,4 +37,12 @@ we generally clear older defunct functions at each minor release, and so you are strongly encouraged to use the new functions/names/syntax wherever possible and update your scripts accordingly. } +\section{Functions}{ +\itemize{ +\item \code{node_by_coreness()}: Deprecated on 2026-08-27. +Renamed \code{node_by_core()}, for symmetry with \code{node_is_core()} and +\code{node_in_core()}, and so that "coreness" names only the peeling depth +that \code{node_by_kcoreness()} returns. + +}} \keyword{internal} diff --git a/man/mark_core.Rd b/man/mark_core.Rd index 85ea802..dd0a777 100644 --- a/man/mark_core.Rd +++ b/man/mark_core.Rd @@ -5,19 +5,31 @@ \alias{node_is_core} \title{Marking nodes as core or periphery} \usage{ -node_is_core(.data, centrality = c("degree", "eigenvector")) +node_is_core( + .data, + coreness = NULL, + direction = c("all", "out", "in"), + centrality = NULL +) } \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{centrality}{Which centrality measure to use to identify cores and periphery. -By default this is "degree", -which relies on the heuristic that high degree nodes are more likely to be in the core. -An alternative is "eigenvector", which instead begins with high eigenvector nodes. -Other methods, such as a genetic algorithm, CONCOR, and Rombach-Porter, -can be added if there is interest.} +\item{coreness}{Which method to use to calculate nodes' coreness. +One of "correlation", "richcore", "transition", or "hub"; +see \link{method_coreness} for what each does. +By default NULL, which uses "richcore" for a weighted, directed, or +two-mode network, since it is the only method that reads those properties +directly, and "correlation" otherwise.} + +\item{direction}{One of "all" (the default), "out", or "in". +For a directed network, "out" scores nodes on the ties they send and +"in" on the ties they receive. +Ignored for undirected and two-mode networks.} + +\item{centrality}{Deprecated; use \code{coreness} instead.} } \value{ A \code{node_mark} logical vector the length of the nodes in the network, @@ -34,12 +46,15 @@ This function is used to identify which nodes should belong to the core, and which to the periphery. It seeks to minimize the following quantity: \deqn{Z(S_1) = \sum_{(i \references{ \subsection{On core-periphery partitioning}{ -Borgatti, Stephen P., & Everett, Martin G. 1999. -"Models of core /periphery structures". -\emph{Social Networks}, 21, 375–395. +Borgatti, Stephen P., and Martin G. Everett. 2000. +"Models of core/periphery structures". +\emph{Social Networks}, 21(4), 375-395. \doi{10.1016/S0378-8733(99)00019-2} Lip, Sean Z. W. 2011. -“A Fast Algorithm for the Discrete Core/Periphery Bipartitioning Problem.” +"A fast algorithm for the discrete core/periphery bipartitioning problem". \doi{10.48550/arXiv.1102.5511} } } diff --git a/man/measure_fit.Rd b/man/measure_fit.Rd index 041c53b..48af706 100644 --- a/man/measure_fit.Rd +++ b/man/measure_fit.Rd @@ -11,7 +11,9 @@ net_by_core( .data, mark = NULL, - method = c("correlation", "ident", "ndiff", "diff") + method = c("correlation", "ident", "ndiff", "diff"), + coreness = NULL, + direction = c("all", "out", "in") ) net_by_factions(.data, membership = NULL) @@ -38,6 +40,18 @@ member of the core and the most core-like member of the periphery. "diff" is similar to "ndiff", but multiplies the raw "ndiff" score by the square root of the size of the core, thus penalising large cores.} +\item{coreness}{Which method to use to calculate nodes' coreness. +One of "correlation", "richcore", "transition", or "hub"; +see \link{method_coreness} for what each does. +By default NULL, which uses "richcore" for a weighted, directed, or +two-mode network, since it is the only method that reads those properties +directly, and "correlation" otherwise.} + +\item{direction}{One of "all" (the default), "out", or "in". +For a directed network, "out" scores nodes on the ties they send and +"in" on the ties they receive. +Ignored for undirected and two-mode networks.} + \item{membership}{A character string naming an existing node attribute in the network, or a categorical vector of the same length as the number of nodes in the network where each element indicates the group membership of @@ -100,10 +114,15 @@ Compare partitions using one measure at a time. } \section{Core-Periphery}{ -\code{net_core()} calculates the Pearson correlation between the given network, -where the nodes in the core are assigned by some given mark, and an ideal -typical core-periphery network with the same number of nodes in the core -and the periphery. +\code{net_by_core()} calculates the Pearson correlation between the given +network, where the nodes in the core are assigned by some given mark, and +an ideal typical core-periphery network with the same number of nodes in +the core and the periphery. + +Where \code{mark} is not given, it is calculated with \code{\link[=node_is_core]{node_is_core()}}, to +which the \code{coreness} and \code{direction} arguments are passed. For a directed +network the fit itself is measured on the symmetrised network, since the +ideal it is compared against is symmetric. } \section{Modularity}{ diff --git a/man/member_core.Rd b/man/member_core.Rd index c7644a6..d5cd621 100644 --- a/man/member_core.Rd +++ b/man/member_core.Rd @@ -5,7 +5,13 @@ \alias{node_in_core} \title{Memberships in core-periphery categories} \usage{ -node_in_core(.data, groups = 3, cluster_by = c("bins", "quantiles", "kmeans")) +node_in_core( + .data, + groups = 3, + cluster_by = c("bins", "quantiles", "kmeans"), + coreness = NULL, + direction = c("all", "out", "in", "both") +) } \arguments{ \item{.data}{A network object of class \code{stocnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. @@ -18,6 +24,18 @@ the number of nodes in the network. Default is 3.} \item{cluster_by}{Method to use to create the categories. One of "bins" (equal-width bins), "quantiles" (quantile-based bins), or "kmeans" (k-means clustering). Default is "bins".} + +\item{coreness}{Which method to use to calculate nodes' coreness. +One of "correlation", "richcore", "transition", or "hub"; +see \link{method_coreness} for what each does. +By default NULL, which uses "richcore" for a weighted, directed, or +two-mode network, since it is the only method that reads those properties +directly, and "correlation" otherwise.} + +\item{direction}{One of "all" (the default), "out", "in", or "both". +For a directed network, "out" scores nodes on the ties they send and +"in" on the ties they receive, while "both" returns the four categories +described below. Ignored for undirected and two-mode networks.} } \value{ A \code{node_member} character vector the length of the nodes in the network, @@ -39,8 +57,25 @@ The categorization can be done using different methods: equal-width bins, quantile-based bins, or k-means clustering. } +\section{Directed core-periphery}{ + +In a directed network a node can be core in whom it reaches and +peripheral in who reaches it, which one core and one periphery cannot +express. \code{direction = "both"} therefore returns the four categories that +Elliott and colleagues distinguish: +\itemize{ +\item "Core" for nodes in both the out-core and the in-core, +\item "Sender" for nodes in the out-core only, +\item "Receiver" for nodes in the in-core only, +\item "Periphery" for nodes in neither. +} + +This uses \code{\link[=coreness_hub]{coreness_hub()}}, so \code{groups} and \code{cluster_by} do not apply. +} + \examples{ node_in_core(ison_adolescents) +node_in_core(ison_networkers, direction = "both") } \references{ \subsection{On core-periphery categorization}{ @@ -48,7 +83,16 @@ node_in_core(ison_adolescents) Wallerstein, Immanuel. 1974. "Dependence in an Interdependent World: The Limited Possibilities of Transformation Within the Capitalist World Economy." \emph{African Studies Review}, 17(1), 1-26. -\doi{https://doi.org/10.2307/523574} +\doi{10.2307/523574} +} + +\subsection{On directed core-periphery}{ + +Elliott, Andrew, Angus Chiu, Marya Bazzi, Gesine Reinert, +and Mihai Cucuringu. 2020. +"Core-periphery structure in directed networks". +\emph{Proceedings of the Royal Society A} 476(2241): 20190783. +\doi{10.1098/rspa.2019.0783} } } \seealso{ diff --git a/man/method_coreness.Rd b/man/method_coreness.Rd index 2aebe1d..ad16d67 100644 --- a/man/method_coreness.Rd +++ b/man/method_coreness.Rd @@ -31,8 +31,10 @@ For a directed network, "out" scores nodes on the ties they send and "in" on the ties they receive. Ignored for undirected and two-mode networks.} -\item{starts}{Integer number of starting points for the search. -By default 5.} +\item{starts}{Integer number of starting points for the search, +at most 9. By default 5. +The starting points are fixed rather than random, so that two calls on +the same network return the same answer.} \item{alpha}{Numeric vector of boundary sharpness values between 0 and 1, to aggregate over. By default \code{seq(0.2, 0.8, 0.2)}.} @@ -54,7 +56,7 @@ directed core-periphery structure distinguishes. \description{ These functions calculate how core-like each node is, returning both a continuous coreness score and a core/periphery split that -\code{\link[=node_is_core]{node_is_core()}}, \code{\link[=node_by_coreness]{node_by_coreness()}} and \code{\link[=node_in_core]{node_in_core()}} then use. +\code{\link[=node_is_core]{node_is_core()}}, \code{\link[=node_by_core]{node_by_core()}} and \code{\link[=node_in_core]{node_in_core()}} then use. \itemize{ \item \code{coreness_correlation()} fits the network to an ideal core-periphery pattern by correlation. diff --git a/tests/testthat/test-member_core.R b/tests/testthat/test-member_core.R index 8a57ca7..42e6e9d 100644 --- a/tests/testthat/test-member_core.R +++ b/tests/testthat/test-member_core.R @@ -7,6 +7,117 @@ test_that("node_kcoreness works", { }) test_that("node_in_core works", { - expect_equal(top3(node_in_core(ison_adolescents)), c("Periphery","Core","Core")) + expect_equal(top3(node_in_core(ison_adolescents)), + c("Periphery", "Semi-periphery", "Core")) expect_output(print(node_in_core(ison_adolescents, groups = 5)), "Semi-periphery-1") }) + +test_that("node_in_core labels the most and least core node correctly", { + # k-means numbers its clusters arbitrarily, so the labels used to land on + # the wrong nodes entirely. + cn <- as.numeric(node_by_core(ison_adolescents)) + for (cb in c("bins", "quantiles", "kmeans")) { + lab <- as.character(node_in_core(ison_adolescents, cluster_by = cb)) + expect_equal(lab[which.max(cn)], "Core", info = cb) + expect_equal(lab[which.min(cn)], "Periphery", info = cb) + } +}) + +test_that("node_in_core numbers middle labels in coreness order", { + # not alphabetical order, which would put "Semi-core-10" before "Semi-core-2" + labs <- core_labels(24) + expect_equal(labs[1], "Core") + expect_equal(labs[length(labs)], "Periphery") + expect_equal(labs[2], "Semi-core-1") + expect_equal(labs[length(labs) - 1], "Semi-periphery-1") +}) + +test_that("node_in_core returns four sets for a directed network", { + out <- node_in_core(ison_networkers, direction = "both") + expect_s3_class(out, "node_member") + expect_true(all(unique(as.character(out)) %in% + c("Core", "Sender", "Receiver", "Periphery"))) + expect_error(node_in_core(ison_adolescents, direction = "both")) +}) + +test_that("node_by_core reads tie direction", { + # reversing every tie must swap the out- and in-scores + out <- as.numeric(node_by_core(ison_networkers, direction = "out")) + ins <- as.numeric(node_by_core(ison_networkers, direction = "in")) + expect_false(isTRUE(all.equal(out, ins))) + rev <- as.numeric(node_by_core(to_redirected(ison_networkers), + direction = "out")) + expect_equal(rev, ins) +}) + +test_that("node_by_core reads tie weights", { + weighted <- as.numeric(node_by_core(ison_networkers)) + binary <- as.numeric(node_by_core(to_unweighted(ison_networkers))) + expect_false(isTRUE(all.equal(weighted, binary))) +}) + +test_that("node_by_core works on a two-mode network", { + # the ideal pattern used to be square, so a non-square network errored + expect_length(node_by_core(ison_southern_women), 32) +}) + +test_that("every coreness method returns a coreness and a core", { + for (fn in list(coreness_correlation, coreness_richcore, + coreness_transition, coreness_hub)) { + out <- fn(ison_adolescents) + expect_length(out$coreness, 8) + expect_true(all(out$coreness >= 0 & out$coreness <= 1)) + expect_type(out$core, "logical") + expect_true(any(out$core) && !all(out$core)) + } +}) + +test_that("the square methods refuse a two-mode network", { + expect_error(coreness_correlation(ison_southern_women)) + expect_error(coreness_transition(ison_southern_women)) +}) + +test_that("node_is_core still accepts the superseded centrality argument", { + expect_warning(node_is_core(ison_adolescents, centrality = "degree")) +}) + +test_that("the superseded node_by_coreness() still works but warns", { + expect_warning(out <- node_by_coreness(ison_adolescents)) + expect_equal(as.numeric(out), as.numeric(node_by_core(ison_adolescents))) +}) + +test_that("the methods recover a planted core", { + set.seed(42) + # five nodes densely and heavily tied, twenty peripheral nodes tied + # sparsely and lightly to them and not to each other + n <- 25 + core <- 1:5 + m <- matrix(0, n, n) + m[core, core] <- 10 + diag(m) <- 0 + for (i in 6:n) { + j <- sample(core, 2) + m[i, j] <- 1 + m[j, i] <- 1 + } + g <- as_igraph(m, twomode = FALSE) + expect_equal(which(coreness_richcore(g)$core), core) + expect_equal(which(coreness_richcore(to_unweighted(g))$core), core) +}) + +test_that("the four sets recover a planted directed structure", { + set.seed(7) + # nodes 1:4 broadcast to nodes 5:8, and both to a periphery of twelve + n <- 20 + senders <- 1:4 + receivers <- 5:8 + d <- matrix(0, n, n) + d[senders, receivers] <- 8 + d[senders, 9:n] <- 1 + for (i in 9:n) d[i, sample(receivers, 1)] <- 1 + out <- as.character(node_in_core(as_igraph(d, twomode = FALSE), + direction = "both")) + expect_equal(which(out == "Sender"), senders) + expect_equal(which(out == "Receiver"), receivers) + expect_equal(sum(out == "Periphery"), 12) +}) From 21c2813e184987570be3bd494e31315758119278 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 19:03:52 +0200 Subject: [PATCH 46/68] Updated topology tutorial with weighted, directed, and continuous core-periphery --- NEWS.md | 1 + inst/tutorials/netrics1/centrality.html | 126 +- inst/tutorials/netrics2/community.html | 242 ++-- inst/tutorials/netrics3/position.html | 1718 ++++++++++++++++------- inst/tutorials/netrics4/topology.Rmd | 213 ++- inst/tutorials/netrics4/topology.html | 1415 +++++++++++++------ vignettes/articles/community.Rmd | 40 - vignettes/articles/position.Rmd | 10 +- vignettes/articles/topology.Rmd | 150 +- 9 files changed, 2769 insertions(+), 1146 deletions(-) diff --git a/NEWS.md b/NEWS.md index 4865a68..c8f0fd5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -144,6 +144,7 @@ ## Tutorials - Updated position tutorial to use `node_in_regular()` for regular equivalence rather than the triad census +- Updated topology tutorial with weighted, directed, and continuous core-periphery # netrics 0.4.1 diff --git a/inst/tutorials/netrics1/centrality.html b/inst/tutorials/netrics1/centrality.html index 523e3a2..4d7c7d6 100644 --- a/inst/tutorials/netrics1/centrality.html +++ b/inst/tutorials/netrics1/centrality.html @@ -1848,11 +1848,11 @@

    Glossary

    @@ -2105,24 +2105,24 @@

    Glossary

    @@ -2338,24 +2338,24 @@

    Glossary

    @@ -2503,24 +2503,24 @@

    Glossary

    @@ -2670,19 +2670,19 @@

    Glossary

    @@ -2843,18 +2843,18 @@

    Glossary

    @@ -2940,19 +2940,19 @@

    Glossary

    @@ -2972,24 +2972,24 @@

    Glossary

    @@ -3138,17 +3138,17 @@

    Glossary

    @@ -3313,19 +3313,19 @@

    Glossary

    @@ -3447,7 +3447,7 @@

    Glossary

    diff --git a/inst/tutorials/netrics2/community.html b/inst/tutorials/netrics2/community.html index 6ce5be2..93ad18e 100644 --- a/inst/tutorials/netrics2/community.html +++ b/inst/tutorials/netrics2/community.html @@ -497,7 +497,6 @@

    Closure

    transitivity , where a directed two-path is likely to be shortened by an additional arc connecting the first and third nodes on that path.

    -

    gif of ah ha gotcha

    Reciprocity

    First, let’s calculate reciprocity in the task network. While one @@ -1086,7 +1085,14 @@

    Given memberships

    net_by_modularity(blogs, membership = node_attribute(blogs, "Leaning"))
    -

    gif of Chevy Chase saying plot twist

    +
    +
    +
    +
    +
    + +
    +

    How interesting. Perhaps the partitioning algorithm is not the algorithm that maximises modularity after all… The blogs’ own declared political leanings describe the network’s tie pattern better @@ -1170,12 +1176,13 @@

    Walktrap

    computationally-prohibitive exhaustive enumeration (Brandes et al. 2008)).”

    So let’s try and get a community classification using the walktrap -algorithm, node_in_walktrap(), with path lengths of the -random walks specified to be 50.

    +algorithm, node_in_walktrap(), with the random walks set to +four steps, which is the default. Longer walks reach further, and tend +to return fewer, larger communities.

    -
    friend_wt <- node_in_walktrap(friends, times=50)
    +
    friend_wt <- node_in_walktrap(friends, steps = 4)
    Walktrap
    -
    friend_wt <- node_in_walktrap(friends, times=50)
    +
    friend_wt <- node_in_walktrap(friends, steps = 4)
     # results in a modularity of
     net_by_modularity(friends, friend_wt)
    @@ -1859,34 +1866,34 @@

    Glossary

    tasks <- to_uniplex(ison_algebra, "tasks") - + + - - @@ -2246,11 +2253,11 @@

    Glossary

    @@ -2471,31 +2478,31 @@

    Glossary

    @@ -2868,31 +2875,31 @@

    Glossary

    @@ -2914,13 +2921,13 @@

    Glossary

    @@ -3009,19 +3016,19 @@

    Glossary

    @@ -3112,23 +3119,23 @@

    Glossary

    @@ -3540,25 +3547,25 @@

    Glossary

    @@ -3646,13 +3653,40 @@

    Glossary

    + + - + + - - + - + + - - + - + + - - + - + + - - + - + + - - + - + + - - + - + + - - + - + + - - + - + +
    diff --git a/inst/tutorials/netrics3/position.html b/inst/tutorials/netrics3/position.html index b371f8d..5fd871d 100644 --- a/inst/tutorials/netrics3/position.html +++ b/inst/tutorials/netrics3/position.html @@ -207,6 +207,9 @@

    Aims

  • +
  • +
  • @@ -474,6 +480,7 @@

    Constraint

    position in the task network — where advice about the algebra problems flows. {netrics} makes this easy with the node_by_constraint() function.

    +
    ## ℹ Assigning alphabetic baby names at random.

    Calculate the constraint score of each node in the tasks network.

    The rest of the family structural-hole position is good practice. For formal definitions and references, see ?measure_broker_node.

    -

    Going further: -Structural holes are closely related to brokerage as a -behaviour: node_by_brokering_activity() and -node_by_brokering_exclusivity() measure how often a node -sits between its contacts and how exclusively, and -node_x_brokerage() counts Gould and Fernandez’s five -brokerage roles (coordinator, gatekeeper, representative, itinerant, -liaison) once nodes belong to known groups.

    +

    Going further: So +far every broker has looked alike — constraint measures how +much brokerage potential a node has, but not what kind. +Once nodes belong to known groups, we can be far more specific: the +Brokerage roles section below unpacks Gould and +Fernandez’s typology of five distinct ways to sit between others.

    In brief: Only @@ -750,6 +755,176 @@

    Ties that torture

    +
    +

    Brokerage roles

    +

    On this page: Five +roles · Counting +roles · Whole +network

    +

    Constraint told us how much brokerage potential a node has, +but it treated every broker alike: a broker is simply someone whose +contacts are not tied to one another. Once nodes belong to known +groups, though, we can ask a sharper question — not just +how much a node brokers, but what kind of brokering it +does. Passing information between two members of your own team is a +rather different act from passing it between two outsiders, or from +being the single gateway through which news reaches your team.

    +

    Gould and Fernandez (1989) turned this intuition into a compact +typology. Take any directed two-path in which a broker B sits +between a source A and a target C (that is, A +→ BC), and look only at which groups the +three belong to. That alone distinguishes five kinds of brokerage.

    +
    +

    Five roles from group membership

    +

    To make the five roles concrete, imagine a law firm with offices in +different cities, and think of B as the lawyer who relays a +piece of advice from A on to C:

    +
      +
    • A coordinator brokers within a single +group: A, B, and C all belong to the same +group (a lawyer relaying advice between two colleagues in their own +office).
    • +
    • An itinerant broker (or consultant) stands +outside the group it serves: A and C share a +group, but the broker B belongs to a different one (an +out-of-town lawyer connecting two colleagues who both work in one +office).
    • +
    • A gatekeeper guards the way in: the source +A is an outsider while the broker B and the target +C share a group, so B governs what reaches its own +group (a lawyer who takes advice from another office and passes it to a +colleague in theirs).
    • +
    • A representative speaks out: the source +A and broker B share a group but the target C +is an outsider, so B governs what its group sends out (a lawyer +relaying a colleague’s advice to another office).
    • +
    • A liaison connects three different worlds: +A, B, and C each belong to a different group +(a lawyer brokering between two other offices, neither of them their +own).
    • +
    +

    Notice that the direction of the tie matters: gatekeeper and +representative are mirror images of one another, distinguished only by +whether the broker sits on the receiving or the +sending side of its own group’s boundary.

    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +

    Counting each node’s roles

    +

    node_x_brokerage() counts, for every node, how many +two-paths it brokers in each of the five roles. It needs two things: a +directed network (so that source and target can be told apart) +and a membership — the groups the roles are defined +against. Those groups are supplied from outside; here we use the advice +network among lawyers in ison_lawfirm, with each lawyer’s +office (Boston, Hartford, or Providence) as the +grouping.

    +

    Run the code to count each lawyer’s brokerage roles in the +advice network, grouped by office.

    +
    +
    node_x_brokerage(lawfirm_advice, "office")
    + +
    +

    Each row is a lawyer and each column one of the five roles, plus a +Total. Reading across a row tells you how that +lawyer brokers, not just how often: most counts fall in the +Coordinator column, because the bulk of advice stays +within an office, while the lawyers with high +Gatekeeper or Representative counts +are the ones mediating across office boundaries — regulating +what flows in to, or out of, their own office. Liaison +counts are the rarest, since a liaison needs a two-path spanning three +different offices at once.

    +

    The raw counts depend on how big and how active each group is, which +makes them hard to compare across nodes. Passing +standardized = TRUE instead returns a z-score for +each role, saying how far above or below chance a node’s count lies +given the group sizes — often the fairer basis for comparison.

    +
    +
    +

    Brokerage across the whole network

    +

    Sometimes we care less about which individual brokers most and more +about the network’s overall brokerage profile: is advice mostly +kept within offices, or does it flow across them? +net_x_brokerage() returns a single count per role for the +network as a whole.

    +

    Summarise the whole advice network’s brokerage profile by +office.

    +
    +
    net_x_brokerage(lawfirm_advice, "office")
    + +
    +

    Comparing the columns tells us how open the firm’s +advice-seeking is: a profile dominated by coordinators describes a firm +where advice stays close to home, whereas large gatekeeper, +representative, and liaison counts would mark a firm whose offices lean +heavily on one another for guidance.

    +

    Try it yourself: node_x_brokerage() +works on any directed network with a group attribute. Try it on +ison_monks, whose nodes carry Sampson’s factional +groups — which monks broker across factions rather +than within them?

    +
    + +
    +
    +
    # ison_monks is directed and carries a "groups" attribute (Sampson's factions).
    +node_x_brokerage(ison_monks, ____)
    +
    +
    +
    # The membership is the name of the node attribute, in quotation marks:
    +node_x_brokerage(ison_monks, "groups")
    +
    +
    +
    node_x_brokerage(ison_monks, "groups")
    +
    +
    +

    Going further: +The Gould-Fernandez roles are one way to characterise brokerage, but not +the only one. node_by_brokering_activity() counts how often +a node sits on a two-path between different groups, +node_by_brokering_exclusivity() counts only the two-paths +where it is the sole broker, and +node_in_brokering() combines the two to label nodes as +powerhouses, connectors, linchpins, or sideliners (Hamilton et +al. 2020). See ?measure_brokerage and +?member_brokerage for details.

    +
    +
    +

    In brief: Once +nodes belong to known groups, node_x_brokerage() counts, +per node, how many two-paths it brokers as a coordinator, +itinerant broker, gatekeeper, representative, +or liaison (Gould and Fernandez’s five roles), and +net_x_brokerage() gives the same profile for the whole +network. Both need a directed network and a +membership; standardized = TRUE returns +z-scores instead of raw counts.

    +
    +
    +

    Structural equivalence

    On this page: Structural equivalence · Choosing k

    -

    gif of rick multiplying

    We now switch from asking about individual positions (who is a broker?) to asking about shared positions: which nodes play the same kind of role? Grouping nodes that occupy similar positions into @@ -910,7 +1084,7 @@

    Step one: starting with a census

    desired. Feel free to explore using some of the other censuses available in {netrics}, though some common ones are already used in the other equivalence convenience functions, -e.g. node_x_triad() in node_in_regular() and +e.g. node_x_triad() in node_in_motif() and node_x_path() in node_in_automorphic() — functions we will actually use later in this tutorial.

    @@ -1108,7 +1282,6 @@

    Blockmodelling

    profiles · Plotting blockmodels

    -

    gif of mortys in a block parade

    Summarising profiles

    Ok, so now we have a result from establishing nodes’ membership in @@ -1134,7 +1307,6 @@

    Summarising profiles

    blockmodelling , and it is the pay-off of all the equivalence work we just did.

    -

    gif of mortys learning about roles

    One option that can be useful for characterising what the profile of ties (partners) is for each position/equivalence class is to use summary(). It summarises some census result by a partition @@ -1285,6 +1457,86 @@

    Plotting blockmodels

    characterises how each class relates to each other class.

    +
    +

    Evaluating a blockmodel

    +

    Reading a blockmodel tells you what a partition says. It +does not tell you how well it says it. Two different partitions +of the same network will both produce a blockmodel you can describe in +words, and you need some way to prefer one over the other.

    +

    The idea is to ask how far each block departs from being +ideal. The two ideal types you have implicitly been using are +the null block, which should contain no ties at all, and the complete +block, which should contain every possible tie. Counting the ties you +would have to add or remove to make every block one or the other gives a +single number: how much the partition has to lie about the network in +order to describe it. net_by_inconsistency() reports +exactly that, normalised by the number of cells, so 0 is a +perfect fit. Note that it therefore runs the opposite way to +most measures — it is a distance from the ideal, so lower is better.

    +

    Compare the structural-equivalence partition of +alge against a coarser one and against a random +partition.

    +
    +
    net_by_inconsistency(alge, node_in_structural(alge))
    +
    +# a coarser partition has to lump dissimilar nodes together
    +net_by_inconsistency(alge, node_in_structural(alge, k = 2))
    +
    +# and an arbitrary partition should do worse than a fitted one
    +net_by_inconsistency(alge, sample(rep(1:4, length.out = net_nodes(alge))))
    + +
    +

    Notice that finer partitions always fit at least as well as coarser +ones, which is why the criterion cannot by itself choose k for +you — in the limit, giving every node its own class fits perfectly and +explains nothing. It compares partitions at a given k.

    +

    Ideal blocks other than null and complete are available through the +blocks argument. A regular block, for +instance, only asks that every row and every column contain at least one +tie, rather than all of them — which is exactly the blockmodel +counterpart of regular equivalence.

    +
    +
    # structural blockmodelling: blocks must be empty or full
    +net_by_inconsistency(alge, node_in_structural(alge, k = 3))
    +
    +# regular blockmodelling: blocks must be empty or have every row and column
    +# represented, which is a much easier standard to meet
    +net_by_inconsistency(alge, node_in_structural(alge, k = 3), blocks = c("nul", "reg"))
    + +
    +

    Since permitting more ideal types can only lower the criterion, +compare partitions only when you have given each the same +vocabulary.

    +

    Finally, once you can score a partition you can search for a good one +directly, rather than clustering a similarity matrix and hoping the +result fits. node_in_block() does this: it tries +partitions, keeps whichever is most consistent, and returns it.

    +

    Search for a three-position blockmodel of alge +and compare it to the structural-equivalence solution.

    +
    +
    set.seed(123)
    +nbm <- node_in_block(alge, k = 3)
    +net_by_inconsistency(alge, nbm)
    +net_by_inconsistency(alge, node_in_structural(alge, k = 3))
    + +
    +

    Because the search is stochastic, running it again may give a +different answer, so set a seed and compare runs with +net_by_inconsistency().

    +
    +

    In brief: +net_by_inconsistency() scores how far a partition’s blocks +are from ideal (0 is perfect, lower is better), with the ideal types set +by blocks; node_in_block() searches directly +for the partition that minimises it.

    +
    +

    Reduced graphs

    @@ -1310,9 +1562,9 @@

    Reduced graphs

    and thus the network will be complex.

    From blocks to ties

    -

    to_blockmodel() carries out this contraction: pass it the -network and a membership vector, and it returns a matrix with one row -and column per class, where each cell holds the average tie +

    to_blockmodel() carries out this contraction: pass it +the network and a membership vector, and it returns a matrix with one +row and column per class, where each cell holds the average tie (weight) from one class to another.

    Contract the algebra network into its four structural-equivalence blocks.

    @@ -1352,8 +1604,8 @@

    Naming positions

    roles.

    In brief: -to_blockmodel() contracts a network into its classes, yielding -a +to_blockmodel() contracts a network into its classes, +yielding a reduced graph whose nodes are positions and whose ties (including loops) are the average ties within and between classes — a @@ -1401,10 +1653,20 @@

    Regular equivalence

    of the three definitions, and usually the closest to the everyday idea of a social “role”: all teachers relate to some students, all students to some teacher, regardless of which particular ones. -Under the hood, node_in_regular() uses a triad census -(node_x_triad()) rather than the tie census, because it -cares about the shape of local neighbourhoods rather than their exact -membership.

    +Under the hood, node_in_regular() works differently from +the census-based functions you have seen so far, because the definition +is recursive: two nodes are equivalent if their alters are +equivalent, whose equivalence depends in turn on their alters. +So instead of building a census once and clustering it, it starts by +assuming every node is equivalent to every other and then repeatedly +revises those similarities until they settle, by checking how well each +node’s alters can be paired up with the other’s. The resulting +similarity matrix is then clustered exactly as before.

    +

    Two algorithms are available via the regularity +argument: "rolesim" (the default) pairs each node’s alters +one-to-one, so that two nodes match only if their neighbourhoods line up +as wholes; "rege" lets the same alter be matched more than +once, which is more permissive, and is what UCINET computes.

    Compute the regular-equivalence classes for alge, plot the dendrogram, and colour the graph by class.

    @@ -1497,11 +1759,11 @@

    Which equivalence when?

    In brief: node_in_structural() (same partners, via a tie census), -node_in_regular() (same pattern / role, via a triad -census), and node_in_automorphic() (interchangeable, via a -path census) are three lenses on position, from strictest to loosest in -theory — though each chooses its own k, so their class counts -won’t nest neatly in practice.

    +node_in_regular() (same pattern / role, via a recursive +similarity), and node_in_automorphic() (interchangeable, +via a path census) are three lenses on position, from strictest to +loosest in theory — though each chooses its own k, so their +class counts won’t nest neatly in practice.

    @@ -1618,48 +1880,75 @@

    Summary

    one group) +node_x_brokerage(), net_x_brokerage() +Gould and Fernandez’s five brokerage roles, per node and for the +whole network + + node_is_min() flags the node(s) with the minimum score, for highlighting - + node_in_structural() structurally equivalent classes (same tie partners; cluster, distance, k, range options) - + node_in_regular(), node_in_automorphic() regularly and automorphically equivalent classes (same role; interchangeable) + +node_in_motif() +classes of similar local embedding, from a triad or tetrad +census + node_x_tie(), node_x_triad(), node_x_path() -the tie, triad, and path censuses behind the three equivalences +the tie, triad, and path censuses behind the census-based +equivalences +regularity_rolesim(), +regularity_rege() +the recursive similarity matrices behind +node_in_regular() + + node_in_equivalence() generic equivalence classes from any node_x_*() census - + plot() on a membership vector draws the dendrogram and its cut-point - + plot(as_matrix(net), membership = ) draws the blockmodel: the matrix sorted into blocks + +net_by_inconsistency() +scores how far a partition’s blocks are from ideal (0 is perfect); +blocks sets which ideals + +node_in_block() +searches directly for the partition that best fits an ideal block +structure + + summary(census, membership = ) averages each class’s census profile - + to_blockmodel() contracts a network into a reduced graph of positions - + graphr(..., node_color = , node_size = ) maps memberships or measures onto the graph @@ -1800,23 +2089,23 @@

    Glossary

    stocnet_theme("default") clear_glossary() learnr::random_phrases_add(language = "fr", - praise = c("C'est gnial!", + praise = c("C'est génial!", "Beau travail", "Excellent travail!", "Bravo!", "Super!", "Bien fait", - "Bien jou", + "Bien joué", "Tu l'as fait!", "Je savais que tu pouvais le faire.", - "a a l'air facile!", - "C'tait un travail de premire classe.", + "Ça a l'air facile!", + "C'était un travail de première classe.", "C'est ce que j'appelle un bon travail!"), encouragement = c("Bon effort", - "Vous l'avez presque matris!", - "a avance bien.", - "Continuez comme a.", - "Continuez travailler dur!", + "Vous l'avez presque maîtrisé!", + "Ça avance bien.", + "Continuez comme ça.", + "Continuez à travailler dur!", "Vous apprenez vite!", "Vous faites un excellent travail aujourd'hui.")) learnr::random_phrases_add(language = "en", @@ -1831,34 +2120,34 @@

    Glossary

    alge <- to_named(ison_algebra) - + + - - @@ -2066,30 +2355,30 @@

    Glossary

    @@ -2115,19 +2404,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "bridges", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2138,10 +2427,10 @@

    Glossary

    ), chunk_opts = list(label = "setup", include = FALSE, purl = FALSE, eval = TRUE)), setup = "alge <- to_named(ison_algebra)\nfriends <- to_uniplex(alge, \"friends\")\nsocial <- to_uniplex(alge, \"social\")\ntasks <- to_uniplex(alge, \"tasks\")", chunks = list(list(label = "objects-setup", code = "alge <- to_named(ison_algebra)\nfriends <- to_uniplex(alge, \"friends\")\nsocial <- to_uniplex(alge, \"social\")\ntasks <- to_uniplex(alge, \"tasks\")", - opts = list(label = "\"objects-setup\"", purl = "FALSE"), - engine = "r"), list(label = "bridges", code = "sum(tie_is_bridge(friends))\nany(node_by_bridges(friends)>0)", - opts = list(label = "\"bridges\"", exercise = "TRUE", - exercise.setup = "\"objects-setup\""), engine = "r")), + opts = list(label = "\"objects-setup\""), engine = "r"), + list(label = "bridges", code = "sum(tie_is_bridge(friends))\nany(node_by_bridges(friends)>0)", + opts = list(label = "\"bridges\"", exercise = "TRUE", + exercise.setup = "\"objects-setup\""), engine = "r")), code_check = NULL, error_check = NULL, check = NULL, solution = NULL, tests = NULL, options = list(eval = FALSE, echo = TRUE, results = "markup", tidy = FALSE, tidy.opts = NULL, collapse = FALSE, prompt = FALSE, @@ -2180,19 +2469,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "constraint", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2203,12 +2492,11 @@

    Glossary

    ), chunk_opts = list(label = "setup", include = FALSE, purl = FALSE, eval = TRUE)), setup = "alge <- to_named(ison_algebra)\nfriends <- to_uniplex(alge, \"friends\")\nsocial <- to_uniplex(alge, \"social\")\ntasks <- to_uniplex(alge, \"tasks\")", chunks = list(list(label = "objects-setup", code = "alge <- to_named(ison_algebra)\nfriends <- to_uniplex(alge, \"friends\")\nsocial <- to_uniplex(alge, \"social\")\ntasks <- to_uniplex(alge, \"tasks\")", - opts = list(label = "\"objects-setup\"", purl = "FALSE"), - engine = "r"), list(label = "constraint", code = "", - opts = list(label = "\"constraint\"", exercise = "TRUE", - exercise.setup = "\"objects-setup\"", purl = "FALSE"), - engine = "r")), code_check = NULL, error_check = NULL, - check = NULL, solution = structure("node_by_constraint(tasks)", chunk_opts = list( + opts = list(label = "\"objects-setup\""), engine = "r"), + list(label = "constraint", code = "", opts = list(label = "\"constraint\"", + exercise = "TRUE", exercise.setup = "\"objects-setup\"", + purl = "FALSE"), engine = "r")), code_check = NULL, + error_check = NULL, check = NULL, solution = structure("node_by_constraint(tasks)", chunk_opts = list( label = "constraint-solution")), tests = NULL, options = list( eval = FALSE, echo = TRUE, results = "markup", tidy = FALSE, tidy.opts = NULL, collapse = FALSE, prompt = FALSE, comment = NA, @@ -2245,19 +2533,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "constraintplot", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2268,11 +2556,11 @@

    Glossary

    ), chunk_opts = list(label = "setup", include = FALSE, purl = FALSE, eval = TRUE)), setup = "alge <- to_named(ison_algebra)\nfriends <- to_uniplex(alge, \"friends\")\nsocial <- to_uniplex(alge, \"social\")\ntasks <- to_uniplex(alge, \"tasks\")", chunks = list(list(label = "objects-setup", code = "alge <- to_named(ison_algebra)\nfriends <- to_uniplex(alge, \"friends\")\nsocial <- to_uniplex(alge, \"social\")\ntasks <- to_uniplex(alge, \"tasks\")", - opts = list(label = "\"objects-setup\"", purl = "FALSE"), - engine = "r"), list(label = "constraintplot", code = "", - opts = list(label = "\"constraintplot\"", exercise = "TRUE", + opts = list(label = "\"objects-setup\""), engine = "r"), + list(label = "constraintplot", code = "", opts = list( + label = "\"constraintplot\"", exercise = "TRUE", exercise.setup = "\"objects-setup\"", purl = "FALSE"), - engine = "r")), code_check = NULL, error_check = NULL, + engine = "r")), code_check = NULL, error_check = NULL, check = NULL, solution = structure(c("tasks <- tasks %>%", " mutate(constraint = node_by_constraint(tasks),", " low_constraint = node_is_min(node_by_constraint(tasks)))", "graphr(tasks, node_size = \"constraint\", node_color = \"low_constraint\")" @@ -2304,11 +2592,11 @@

    Glossary

    @@ -2336,19 +2624,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "shfamily", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2359,10 +2647,10 @@

    Glossary

    ), chunk_opts = list(label = "setup", include = FALSE, purl = FALSE, eval = TRUE)), setup = "alge <- to_named(ison_algebra)\nfriends <- to_uniplex(alge, \"friends\")\nsocial <- to_uniplex(alge, \"social\")\ntasks <- to_uniplex(alge, \"tasks\")", chunks = list(list(label = "objects-setup", code = "alge <- to_named(ison_algebra)\nfriends <- to_uniplex(alge, \"friends\")\nsocial <- to_uniplex(alge, \"social\")\ntasks <- to_uniplex(alge, \"tasks\")", - opts = list(label = "\"objects-setup\"", purl = "FALSE"), - engine = "r"), list(label = "shfamily", code = "node_by_effsize(tasks)\nnode_by_redundancy(tasks)\nnode_by_hierarchy(tasks)", - opts = list(label = "\"shfamily\"", exercise = "TRUE", - exercise.setup = "\"objects-setup\""), engine = "r")), + opts = list(label = "\"objects-setup\""), engine = "r"), + list(label = "shfamily", code = "node_by_effsize(tasks)\nnode_by_redundancy(tasks)\nnode_by_hierarchy(tasks)", + opts = list(label = "\"shfamily\"", exercise = "TRUE", + exercise.setup = "\"objects-setup\""), engine = "r")), code_check = NULL, error_check = NULL, check = NULL, solution = NULL, tests = NULL, options = list(eval = FALSE, echo = TRUE, results = "markup", tidy = FALSE, tidy.opts = NULL, collapse = FALSE, prompt = FALSE, @@ -2393,28 +2681,28 @@

    Glossary

    @@ -2440,19 +2728,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "folds", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2463,18 +2751,19 @@

    Glossary

    ), chunk_opts = list(label = "setup", include = FALSE, purl = FALSE, eval = TRUE)), setup = "alge <- to_named(ison_algebra)\nfriends <- to_uniplex(alge, \"friends\")\nsocial <- to_uniplex(alge, \"social\")\ntasks <- to_uniplex(alge, \"tasks\")", chunks = list(list(label = "objects-setup", code = "alge <- to_named(ison_algebra)\nfriends <- to_uniplex(alge, \"friends\")\nsocial <- to_uniplex(alge, \"social\")\ntasks <- to_uniplex(alge, \"tasks\")", - opts = list(label = "\"objects-setup\"", purl = "FALSE"), - engine = "r"), list(label = "folds", code = "node_is_fold(alge)\nany(node_is_fold(alge))", - opts = list(label = "\"folds\"", exercise = "TRUE", exercise.setup = "\"objects-setup\""), - engine = "r")), code_check = NULL, error_check = NULL, - check = NULL, solution = NULL, tests = NULL, options = list( - eval = FALSE, echo = TRUE, results = "markup", tidy = FALSE, - tidy.opts = NULL, collapse = FALSE, prompt = FALSE, comment = NA, - highlight = FALSE, size = "normalsize", background = "#F7F7F7", - strip.white = TRUE, cache = 0, cache.path = "position_cache/html/", - cache.vars = NULL, cache.lazy = TRUE, dependson = NULL, - autodep = FALSE, cache.rebuild = FALSE, fig.keep = "high", - fig.show = "asis", fig.align = "default", fig.path = "position_files/figure-html/", + opts = list(label = "\"objects-setup\""), engine = "r"), + list(label = "folds", code = "node_is_fold(alge)\nany(node_is_fold(alge))", + opts = list(label = "\"folds\"", exercise = "TRUE", + exercise.setup = "\"objects-setup\""), engine = "r")), + code_check = NULL, error_check = NULL, check = NULL, solution = NULL, + tests = NULL, options = list(eval = FALSE, echo = TRUE, results = "markup", + tidy = FALSE, tidy.opts = NULL, collapse = FALSE, prompt = FALSE, + comment = NA, highlight = FALSE, size = "normalsize", + background = "#F7F7F7", strip.white = TRUE, cache = 0, + cache.path = "position_cache/html/", cache.vars = NULL, + cache.lazy = TRUE, dependson = NULL, autodep = FALSE, + cache.rebuild = FALSE, fig.keep = "high", fig.show = "asis", + fig.align = "default", fig.path = "position_files/figure-html/", dev = "png", dev.args = NULL, dpi = 192, fig.ext = "png", fig.width = 6.5, fig.height = 4, fig.env = "figure", fig.cap = NULL, fig.scap = NULL, fig.lp = "fig:", fig.subcap = NULL, @@ -2492,28 +2781,28 @@

    Glossary

    - - - + + + + + + + + + + + + + + + + + + + + - + + - - + - + + - - + - + + - - + - + + - - + - + + - - + - + + - - + - + + - - + + + + + + + + + + + + + + + + + - - + - + + - - + - + + - - + - + + - - + - + + - - + - + + - - + - + + - - +
    diff --git a/inst/tutorials/netrics4/topology.Rmd b/inst/tutorials/netrics4/topology.Rmd index 38e0ba8..d166f95 100644 --- a/inst/tutorials/netrics4/topology.Rmd +++ b/inst/tutorials/netrics4/topology.Rmd @@ -675,6 +675,8 @@ attaches to like. On this page: Ideal graphs · Assignment · +Weighted · +Directed · Coreness ### Core-periphery graphs {#core-periphery-graphs} @@ -850,13 +852,168 @@ question("There a statistically significant association between the core assignm ) ``` +### Weighted cores {#weighted-cores} + +So far we have treated every tie as the same. +But in networks such as trade networks, airline networks, or communication networks, +the ties can carry very different weights. +Being part of the core is not just about having many ties, +but about having many *strong* ties. + +`ison_networkers` is a network of messages sent between researchers on an +electronic information exchange system. +It is both weighted, by the number of messages, and directed. +**Run the code to see how many messages the busiest ties carry.** + +```{r netw, exercise=TRUE} +netw <- ison_networkers +summary(tie_weights(netw)) +``` + +For a weighted network, `{netrics}` uses the *rich-core* method by default. +It ranks the nodes by the total weight of their ties, not their count, +and walks down that ranking, +adding up the weight each node sends to the nodes ranked above it. +Added nodes that are still strongly tied to those already in the core will increase that total; +but a node that is not strongly tied to those already assigned to the core +will not add much, and the peak of this total signals the boundary of the core. +**Assign core membership on the weighted network, then on the same network +with its weights removed, and compare the two.** + +```{r wcore, exercise=TRUE, purl = FALSE, exercise.setup="netw"} + +``` + +```{r wcore-solution} +weighted_core <- node_is_core(netw) +binary_core <- node_is_core(to_unweighted(netw)) +table(weighted = weighted_core, binary = binary_core) +``` + +Note that the two do not agree. +The weighted core assigns six researchers to the core, +while the binary core assigns 15. +That means nine researchers who have many correspondents nonetheless +do not exchange enough messages with the busiest others to survive the weighted cut. +Ignoring weights means the core is who exchange messages with the most others. +Reading the weights means it is the set of researchers who +exchange the most messages with each other. +Neither answer is the right one in general -- but you should know +which one you asked for. + +```{r weight-qa, echo=FALSE, purl = FALSE} +question("A node in a large, dense network has only three correspondents, but exchanges a very large number of messages with each. Which method is more likely to place it in the core?", + answer("The weighted method, since strength counts the messages and degree counts only the correspondents.", correct = TRUE, + message = learnr::random_praise()), + answer("The binary method, since three ties is already unusual.", + message = learnr::random_encouragement()), + answer("Either, since the two always agree.", + message = learnr::random_encouragement()), + answer("Neither, since three ties is too few for any method.", + message = learnr::random_encouragement()), + random_answer_order = TRUE, + allow_retry = TRUE) +``` + +::: {.callout} +**In brief**: On a weighted network, +`node_is_core()` and `node_by_core()` read the weights by default. +To ask the binary question instead, drop the weights first with +`to_unweighted()`. +::: + +### Directed cores {#directed-cores} + +Notice that at the very start of this section we wrote `to_undirected()`, +throwing away which partner named which. +For a core-periphery question that may be a real loss, +because tie direction can matter. +Indeed, a node could be core in one direction and peripheral in the other. +It might be core in *whom it reaches* yet peripheral in *who reaches it*. +An influencer might be followed by everyone yet follow no one; +an enthusiastic subscriber might follow everyone yet be followed by no one. +Let us keep the direction this time. +**Extract the friendship network again, without dropping direction.** + +```{r dlaw, exercise=TRUE} +lawdir <- ison_lawfirm |> to_uniplex("friends") +is_directed(lawdir) +``` + +`node_by_core()` takes a `direction` argument, exactly as +`node_by_degree()` does. **Score the partners on the friendships they name, +then on the friendships they are named in, and see how far the two agree.** + +```{r dcore, exercise=TRUE, purl = FALSE, exercise.setup="dlaw"} + +``` + +```{r dcore-solution} +names_others <- node_by_core(lawdir, direction = "out") +named_by_others <- node_by_core(lawdir, direction = "in") +cor(as.numeric(names_others), as.numeric(named_by_others)) +plot(as.numeric(names_others), as.numeric(named_by_others), + xlab = "core in whom they name", ylab = "core in who names them") +``` + +The two correlate, but only moderately. +Partners sitting off the diagonal are effectively core in one direction but peripheral in the other, +meaning a single core-periphery split misdescribes their position. +`node_in_core(direction = "both")` keeps both answers, returning the four +categories that Elliott and colleagues distinguish: +**Core** for partners in both the out-core and the in-core, +**Sender** for partners in the out-core only, +**Receiver** for partners in the in-core only, +and **Periphery** for partners in neither. +**Assign the four categories and graph the network coloured by them.** + +```{r fourset, exercise=TRUE, purl = FALSE, exercise.setup="dlaw"} + +``` + +```{r fourset-solution} +table(node_in_core(lawdir, direction = "both")) +lawdir |> + mutate_nodes(cp = node_in_core(direction = "both")) |> + graphr(node_color = "cp", edge_color = "gray") +``` + +Most partners are peripheral, +but the remainder splits three ways rather than falling into one core. +The Receivers are named as friends more than they name others, +and the Senders the reverse. + +```{r dir-qa, echo=FALSE, purl = FALSE} +question("A partner is named by many others but names almost nobody. Which category do they fall into?", + answer("Receiver", correct = TRUE, + message = "Yes -- they are in the in-core but not the out-core."), + answer("Sender", + message = learnr::random_encouragement()), + answer("Core", + message = learnr::random_encouragement()), + answer("Periphery", + message = learnr::random_encouragement()), + random_answer_order = TRUE, + allow_retry = TRUE) +``` + ### Coreness {#coreness} -An alternative route is to identify 'core' nodes -depending on their `r gloss("k-coreness","kcoreness")`. -In `{manynet}`, we can return nodes _k_-coreness -with `node_by_kcoreness()` instead of -the `node_is_core()` used for core-periphery. +`node_is_core()` classifies nodes binarily into core and periphery. +Two other functions grade the question instead, but they grade different things. + +The first is `node_by_core()`, which returns the continuous score used by `node_is_core()`. +It scores each node between 0 and 1 for how closely it resembles a typical core node. +**Score the law-firm network and compare the two.** + +```{r nodecoren0, exercise=TRUE, exercise.setup="gnet"} +lawfirm |> + mutate_nodes(cness = node_by_core(), core = node_is_core()) |> + graphr(node_size = "cness", node_color = "core") +``` + +The second, `node_by_kcoreness()`, returns `r gloss("k-coreness","kcoreness")`. +This answers a different question: how deeply embedded does a node sit (in a network's core)? **Run the code to colour the law-firm network by each node's _k_-coreness.** ```{r nodecoren, exercise=TRUE, exercise.setup="gnet"} @@ -865,24 +1022,21 @@ lawfirm |> graphr(node_color = "ncn") ``` -Where `node_is_core()` forces a yes/no answer, -_k_-coreness grades how deep each node sits in the network: -a node with coreness _k_ survives even after all nodes of degree -less than _k_ have been successively peeled away. +A node with coreness _k_ survives even after all nodes of degree less than _k_ +have been successively peeled away. High-coreness nodes are thus embedded in a densely interlocked middle, -which matters for processes like diffusion — -what starts in a high _k_-core is far more likely to spread widely -than what starts among the peelable outer layers. +which matters for processes like diffusion. -```{r dich-qa, echo=FALSE, purl = FALSE} -question("Which has more than two classes/groups.", - answer("node_kcoreness()", correct = TRUE, +```{r three-qa, echo=FALSE, purl = FALSE} +question("Match the function to the question it answers.", + answer("node_is_core() answers yes or no; node_by_core() grades how core-like a node is; node_by_kcoreness() grades how deep it sits.", correct = TRUE, message = learnr::random_praise()), - answer("node_is_core()", + answer("All three grade the same thing, at different levels of detail.", + message = "Not quite -- coreness and k-coreness measure different things. A node can look core-like and still be peeled away early."), + answer("node_by_core() and node_by_kcoreness() always agree.", message = learnr::random_encouragement()), random_answer_order = TRUE, - allow_retry = TRUE -) + allow_retry = TRUE) ``` ```{r ness-qa, echo=FALSE, purl = FALSE} @@ -899,11 +1053,20 @@ question("Select the correct definitions:", ``` ::: {.callout} -**In brief**: `create_core()` draws the ideal core-periphery -network; `node_is_core()` assigns each node to core or periphery, and -`net_by_core()` reports how well that bipartition actually fits the network -(1 = perfectly, ~0 = not at all). `node_by_kcoreness()` offers a graded -alternative, peeling the network into successively deeper _k_-cores. +**In brief**: `create_core()` draws the ideal core-periphery network; +`node_is_core()` assigns each node to core or periphery, +`node_in_core()` assigns each node into potentially more refined categories, +and `node_by_core()` returns the continuous score behind that assignment. +`node_by_kcoreness()` peels the network into successively deeper _k_-cores. +`net_by_core()` reports how well a bipartition actually fits the network +(1 = perfectly, ~0 = not at all). + +All of these take a `coreness` argument naming which method to use, +and a `direction` argument saying which ties to read. +By default the method follows the network: +the rich-core method for a weighted, directed, or two-mode network, +since it is the only one that reads those properties directly, +and the correlation method otherwise. ::: ## Hierarchy @@ -1264,8 +1427,12 @@ Along the way, you have learned to use these functions: | `net_by_scalefree()` | power-law exponent fitted to the degree distribution | | `net_by_richclub()`, `net_by_assortativity()` | whether hubs interconnect, and whether like degrees attach to like | | `node_is_core()`, `node_in_core()` | assigns nodes to core/periphery (or core/semi-periphery/periphery) | +| `node_in_core(direction = "both")` | the four categories of a directed structure: Core, Sender, Receiver, Periphery | +| `node_by_core()` | continuous score, 0 to 1, for how core-like each node is | | `net_by_core()` | correlation of the network with an ideal core-periphery model | | `node_by_kcoreness()` | each node's _k_-coreness (depth in the network's successive cores) | +| `coreness_richcore()`, `coreness_correlation()` | the methods behind those functions; `coreness_richcore()` reads weights and direction | +| `tie_weights()`, `to_unweighted()` | read the tie weights, or drop them | | `net_x_hierarchy()` | Krackhardt's four graph-theoretic dimensions of hierarchy | | `net_by_connectedness()` | proportion of dyads that can reach each other | | `net_by_cohesion()`, `net_by_adhesion()` | minimum nodes / ties to remove to fragment the network | diff --git a/inst/tutorials/netrics4/topology.html b/inst/tutorials/netrics4/topology.html index 8781a26..d18fdb1 100644 --- a/inst/tutorials/netrics4/topology.html +++ b/inst/tutorials/netrics4/topology.html @@ -224,7 +224,6 @@

    Creating networks

    onclick="document.getElementById('section-lattices').scrollIntoView({behavior:'auto',block:'start'});">Lattices
    · Rings

    -

    In this practical, we’re going to create/generate a number of ideal-typical network topologies and plot them. We’ll first look at some deterministic algorithms for creating networks of different @@ -437,9 +436,9 @@

    Rings

    (graphr(create_ring(50, width = 2), "stress") + ggtitle("The Ring Two v2.0"))

    The price a ring pays for its regularity is distance. The -<dfn title=‘A network’s diameter is the maximum length of any -shortest path.’> diameter of a network is the length of -its longest shortest path + +diameter of a network is the length of its longest +shortest path ( geodesic ), and net_by_length() returns the average shortest path length. Check how far apart nodes @@ -784,6 +783,10 @@

    Core-Periphery

    graphs · Assignment · WeightedDirectedCoreness

    Core-periphery graphs

    @@ -945,14 +948,163 @@

    Core-periphery assignment

    +
    +

    Weighted cores

    +

    So far we have treated every tie as the same. But in networks such as +trade networks, airline networks, or communication networks, the ties +can carry very different weights. Being part of the core is not just +about having many ties, but about having many strong ties.

    +

    ison_networkers is a network of messages sent between +researchers on an electronic information exchange system. It is both +weighted, by the number of messages, and directed. Run the code +to see how many messages the busiest ties carry.

    +
    +
    netw <- ison_networkers
    +summary(tie_weights(netw))
    + +
    +

    For a weighted network, {netrics} uses the +rich-core method by default. It ranks the nodes by the total +weight of their ties, not their count, and walks down that ranking, +adding up the weight each node sends to the nodes ranked above it. Added +nodes that are still strongly tied to those already in the core will +increase that total; but a node that is not strongly tied to those +already assigned to the core will not add much, and the peak of this +total signals the boundary of the core. Assign core membership +on the weighted network, then on the same network with its weights +removed, and compare the two.

    +
    + +
    +
    +
    weighted_core <- node_is_core(netw)
    +binary_core <- node_is_core(to_unweighted(netw))
    +table(weighted = weighted_core, binary = binary_core)
    +
    +

    Note that the two do not agree. The weighted core assigns six +researchers to the core, while the binary core assigns 15. That means +nine researchers who have many correspondents nonetheless do not +exchange enough messages with the busiest others to survive the weighted +cut. Ignoring weights means the core is who exchange messages with the +most others. Reading the weights means it is the set of researchers who +exchange the most messages with each other. Neither answer is the right +one in general – but you should know which one you asked for.

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    In brief: On a +weighted network, node_is_core() and +node_by_core() read the weights by default. To ask the +binary question instead, drop the weights first with +to_unweighted().

    +
    +
    +
    +

    Directed cores

    +

    Notice that at the very start of this section we wrote +to_undirected(), throwing away which partner named which. +For a core-periphery question that may be a real loss, because tie +direction can matter. Indeed, a node could be core in one direction and +peripheral in the other. It might be core in whom it reaches +yet peripheral in who reaches it. An influencer might be +followed by everyone yet follow no one; an enthusiastic subscriber might +follow everyone yet be followed by no one. Let us keep the direction +this time. Extract the friendship network again, without +dropping direction.

    +
    +
    lawdir <- ison_lawfirm |> to_uniplex("friends")
    +is_directed(lawdir)
    + +
    +

    node_by_core() takes a direction argument, +exactly as node_by_degree() does. Score the +partners on the friendships they name, then on the friendships they are +named in, and see how far the two agree.

    +
    + +
    +
    +
    names_others <- node_by_core(lawdir, direction = "out")
    +named_by_others <- node_by_core(lawdir, direction = "in")
    +cor(as.numeric(names_others), as.numeric(named_by_others))
    +plot(as.numeric(names_others), as.numeric(named_by_others),
    +     xlab = "core in whom they name", ylab = "core in who names them")
    +
    +

    The two correlate, but only moderately. Partners sitting off the +diagonal are effectively core in one direction but peripheral in the +other, meaning a single core-periphery split misdescribes their +position. node_in_core(direction = "both") keeps both +answers, returning the four categories that Elliott and colleagues +distinguish: Core for partners in both the out-core and +the in-core, Sender for partners in the out-core only, +Receiver for partners in the in-core only, and +Periphery for partners in neither. Assign the +four categories and graph the network coloured by them.

    +
    + +
    +
    +
    table(node_in_core(lawdir, direction = "both"))
    +lawdir |>
    +  mutate_nodes(cp = node_in_core(direction = "both")) |>
    +  graphr(node_color = "cp", edge_color = "gray")
    +
    +

    Most partners are peripheral, but the remainder splits three ways +rather than falling into one core. The Receivers are named as friends +more than they name others, and the Senders the reverse.

    +
    +
    +
    +
    +
    + +
    +
    +

    Coreness

    -

    An alternative route is to identify ‘core’ nodes depending on their +

    node_is_core() classifies nodes binarily into core and +periphery. Two other functions grade the question instead, but they +grade different things.

    +

    The first is node_by_core(), which returns the +continuous score used by node_is_core(). It scores each +node between 0 and 1 for how closely it resembles a typical core node. +Score the law-firm network and compare the two.

    +
    +
    lawfirm |>
    +  mutate_nodes(cness = node_by_core(), core = node_is_core()) |>
    +  graphr(node_size = "cness", node_color = "core")
    + +
    +

    The second, node_by_kcoreness(), returns -k-coreness . In {manynet}, we can return nodes -k-coreness with node_by_kcoreness() instead of the -node_is_core() used for core-periphery. Run the -code to colour the law-firm network by each node’s +k-coreness . This answers a different question: how deeply +embedded does a node sit (in a network’s core)? Run the code to +colour the law-firm network by each node’s k-coreness.

    Coreness graphr(node_color = "ncn")
    -

    Where node_is_core() forces a yes/no answer, -k-coreness grades how deep each node sits in the network: a -node with coreness k survives even after all nodes of degree -less than k have been successively peeled away. High-coreness -nodes are thus embedded in a densely interlocked middle, which matters -for processes like diffusion — what starts in a high k-core is -far more likely to spread widely than what starts among the peelable -outer layers.

    +

    A node with coreness k survives even after all nodes of +degree less than k have been successively peeled away. +High-coreness nodes are thus embedded in a densely interlocked middle, +which matters for processes like diffusion.

    -
    -
    -
    -
    +
    +
    +
    +
    @@ -989,11 +1137,20 @@

    Coreness

    In brief: create_core() draws the ideal core-periphery network; -node_is_core() assigns each node to core or periphery, and -net_by_core() reports how well that bipartition actually -fits the network (1 = perfectly, ~0 = not at all). -node_by_kcoreness() offers a graded alternative, peeling -the network into successively deeper k-cores.

    +node_is_core() assigns each node to core or periphery, +node_in_core() assigns each node into potentially more +refined categories, and node_by_core() returns the +continuous score behind that assignment. +node_by_kcoreness() peels the network into successively +deeper k-cores. net_by_core() reports how well a +bipartition actually fits the network (1 = perfectly, ~0 = not at +all).

    +

    All of these take a coreness argument naming which +method to use, and a direction argument saying which ties +to read. By default the method follows the network: the rich-core method +for a weighted, directed, or two-mode network, since it is the only one +that reads those properties directly, and the correlation method +otherwise.

    @@ -1448,6 +1605,15 @@

    Summary

    core/semi-periphery/periphery) +node_in_core(direction = "both") +the four categories of a directed structure: Core, Sender, Receiver, +Periphery + + +node_by_core() +continuous score, 0 to 1, for how core-like each node is + + net_by_core() correlation of the network with an ideal core-periphery model @@ -1457,6 +1623,16 @@

    Summary

    cores) +coreness_richcore(), +coreness_correlation() +the methods behind those functions; coreness_richcore() +reads weights and direction + + +tie_weights(), to_unweighted() +read the tie weights, or drop them + + net_x_hierarchy() Krackhardt’s four graph-theoretic dimensions of hierarchy @@ -1574,7 +1750,7 @@

    Glossary

    Diameter
    -A network’s diameter is the maximum length of any shortest path. +The diameter of a network is the maximum length of any shortest path.
    Distribution @@ -1708,23 +1884,23 @@

    Glossary

    stocnet_theme("default") clear_glossary() learnr::random_phrases_add(language = "fr", - praise = c("C'est gnial!", + praise = c("C'est génial!", "Beau travail", "Excellent travail!", "Bravo!", "Super!", "Bien fait", - "Bien jou", + "Bien joué", "Tu l'as fait!", "Je savais que tu pouvais le faire.", - "a a l'air facile!", - "C'tait un travail de premire classe.", + "Ça a l'air facile!", + "C'était un travail de première classe.", "C'est ce que j'appelle un bon travail!"), encouragement = c("Bon effort", - "Vous l'avez presque matris!", - "a avance bien.", - "Continuez comme a.", - "Continuez travailler dur!", + "Vous l'avez presque maîtrisé!", + "Ça avance bien.", + "Continuez comme ça.", + "Continuez à travailler dur!", "Vous apprenez vite!", "Vous faites un excellent travail aujourd'hui.")) learnr::random_phrases_add(language = "en", @@ -1735,34 +1911,34 @@

    Glossary

    encouragement = c("Good effort")) - + + - - @@ -1997,19 +2173,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "lattices", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2057,19 +2233,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "latmeasures", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2116,19 +2292,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "rings", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2177,19 +2353,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "ringmeasures", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2237,19 +2413,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "random", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2298,19 +2474,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "randomno", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2356,19 +2532,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "smallw", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2417,19 +2593,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "swcompare", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2477,19 +2653,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "smallwtest", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2535,19 +2711,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "scalef", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2597,19 +2773,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "scaleftest", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2655,19 +2831,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "degmix", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2717,19 +2893,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "moregen", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2769,13 +2945,13 @@

    Glossary

    @@ -2801,19 +2977,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "core", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2859,19 +3035,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "gnet", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2923,19 +3099,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "nodecore", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -2987,19 +3163,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "netcore", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3041,30 +3217,30 @@

    Glossary

    @@ -3090,19 +3266,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "chisq", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3147,27 +3323,27 @@

    Glossary

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3303,34 +3928,34 @@

    Glossary

    @@ -3356,19 +3981,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "treeh", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3415,19 +4040,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "hierarchy", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3467,25 +4092,25 @@

    Glossary

    @@ -3511,19 +4136,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "connected", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3561,18 +4186,18 @@

    Glossary

    @@ -3598,19 +4223,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "cohesion", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3646,20 +4271,20 @@

    Glossary

    @@ -3714,19 +4339,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "idcuts", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3772,19 +4397,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "closerlook", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3832,19 +4457,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "tieside", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3892,19 +4517,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "tiecoh", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -3952,19 +4577,19 @@

    Glossary

    learnr:::store_exercise_cache(structure(list(label = "freeplay", global_setup = structure(c("library(learnr)", "knitr::opts_chunk$set(echo = FALSE)", "library(netrics)", "library(autograph)", "stocnet_theme(\"default\")", "clear_glossary()", "learnr::random_phrases_add(language = \"fr\",", -" praise = c(\"C'est gnial!\",", +" praise = c(\"C'est génial!\",", " \"Beau travail\",", " \"Excellent travail!\",", " \"Bravo!\",", " \"Super!\",", -" \"Bien fait\",", " \"Bien jou\",", +" \"Bien fait\",", " \"Bien joué\",", " \"Tu l'as fait!\",", " \"Je savais que tu pouvais le faire.\",", -" \"a a l'air facile!\",", -" \"C'tait un travail de premire classe.\",", +" \"Ça a l'air facile!\",", +" \"C'était un travail de première classe.\",", " \"C'est ce que j'appelle un bon travail!\"),", " encouragement = c(\"Bon effort\",", -" \"Vous l'avez presque matris!\",", -" \"a avance bien.\",", -" \"Continuez comme a.\",", -" \"Continuez travailler dur!\",", +" \"Vous l'avez presque maîtrisé!\",", +" \"Ça avance bien.\",", +" \"Continuez comme ça.\",", +" \"Continuez à travailler dur!\",", " \"Vous apprenez vite!\",", " \"Vous faites un excellent travail aujourd'hui.\"))", "learnr::random_phrases_add(language = \"en\",", " praise = c(\"That's brilliant!\",", @@ -4000,12 +4625,12 @@

    Glossary

    diff --git a/vignettes/articles/community.Rmd b/vignettes/articles/community.Rmd index 88a80a5..95ff9c8 100644 --- a/vignettes/articles/community.Rmd +++ b/vignettes/articles/community.Rmd @@ -871,45 +871,6 @@ and `node_in_eigen()` (spectral). See `?member_community` for the full list with definitions and references. ::: -### A target number of communities {#target-k} - -Each of these algorithms decides for itself how many communities there are, -usually by maximising modularity. -Sometimes you want a particular number instead, -for example because you want to compare the result against -an attribute that already has three categories. -Pass that number as `k`: - -```{r targetk} -node_in_walktrap(friends, k = 3) -node_in_louvain(friends, k = 3) -``` - -The route to `k` differs by algorithm. -The hierarchical algorithms cut their dendrogram at `k`. -`node_in_louvain()` and `node_in_leiden()` search their resolution parameter -for the value that returns `k` communities. -`node_in_fluid()` and `node_in_partition()` take `k` directly. -`node_in_labels()` fixes the labels of the `k` best-connected nodes, -propagates from there, and merges any surplus groups. - -Not every network can be split into any number of communities. -Where the requested number is out of reach, -the function says so and returns the nearest partition it can find. - -`k` also accepts the name of a selection method, -which chooses the number for you on a criterion other than modularity: - -```{r selectk} -node_in_betweenness(friends, k = "silhouette") -``` - -`"silhouette"` prefers the number of communities where nodes sit close to -others in their own community and far from the nearest other community. -`"strict"` returns the partition in which no tie crosses a group at all, -which is the components. -These are the same method names that `node_in_equivalence()` accepts. - ::: {.callout} **In brief**: Community detection algorithms cluster nodes by tie density: `node_in_walktrap()` via random walks, @@ -976,7 +937,6 @@ Along the way, you have learned to use these functions: | `node_attribute()` | extracts a nodal attribute, e.g. as an empirical membership | | `node_in_walktrap()`, `node_in_betweenness()`, `node_in_greedy()` | community detection via random walks, divisive tie removal, agglomerative merges | | `node_in_community()` | surveys applicable algorithms and returns the best assignment by modularity | -| `node_in_*(k = )` | targets a number of communities, or names a method (`"silhouette"`, `"strict"`) to choose it | | `mutate_nodes()`, `mutate_ties()` | adds measures or memberships to the network for graphing | | `graphr(..., node_color = , node_group = , edge_color = )` | maps memberships onto colours and group outlines | diff --git a/vignettes/articles/position.Rmd b/vignettes/articles/position.Rmd index f25760d..78a67d7 100644 --- a/vignettes/articles/position.Rmd +++ b/vignettes/articles/position.Rmd @@ -150,7 +150,7 @@ By the end of this tutorial, you should be able to: - [ ]   Partition a network into equivalent classes, and understand the census, clustering, and _k_-selection choices behind it - [ ]   Read a dendrogram and a blockmodel, and justify a choice of _k_ - [ ]   Score how well a partition fits with `net_by_inconsistency()`, and search for one directly with `node_in_block()` -- [ ]   Contract a network into a reduced graph of positions with `to_blocks()` +- [ ]   Contract a network into a reduced graph of positions with `to_blockmodel()` **Choose your own data**: The worked examples below use `ison_algebra`, a multiplex network of interactions in an algebra class @@ -1118,7 +1118,7 @@ any within-class ties will end up becoming loops and thus the network will be co ### From blocks to ties {#from-blocks-to-ties} -`to_blocks()` carries out this contraction: +`to_blockmodel()` carries out this contraction: pass it the network and a membership vector, and it returns a matrix with one row and column per _class_, where each cell holds the average tie (weight) from one class to another. @@ -1126,7 +1126,7 @@ where each cell holds the average tie (weight) from one class to another. **Contract the algebra network into its four structural-equivalence blocks.** ```{r structblock, warning=FALSE} -(bm <- to_blocks(alge, node_in_structural(alge))) +(bm <- to_blockmodel(alge, node_in_structural(alge))) ``` Notice how this is just a compact, numerical version of the blockmodel plot @@ -1160,7 +1160,7 @@ a network of sixteen individuals compressed into a comprehensible structure of four roles. ::: {.callout} -**In brief**: `to_blocks()` contracts a network into its classes, +**In brief**: `to_blockmodel()` contracts a network into its classes, yielding a `r gloss("reduced graph","reduced")` whose nodes are positions and whose ties (including loops) are the average ties within and between classes — a role-level summary of the whole network. @@ -1357,7 +1357,7 @@ Along the way, you have learned to use these functions: | `net_by_inconsistency()` | scores how far a partition's blocks are from ideal (0 is perfect); `blocks` sets which ideals | | `node_in_block()` | searches directly for the partition that best fits an ideal block structure | | `summary(census, membership = )` | averages each class's census profile | -| `to_blocks()` | contracts a network into a reduced graph of positions | +| `to_blockmodel()` | contracts a network into a reduced graph of positions | | `graphr(..., node_color = , node_size = )` | maps memberships or measures onto the graph | When you are ready, continue with the other `{netrics}` tutorials — diff --git a/vignettes/articles/topology.Rmd b/vignettes/articles/topology.Rmd index f830b5a..b92957c 100644 --- a/vignettes/articles/topology.Rmd +++ b/vignettes/articles/topology.Rmd @@ -559,6 +559,8 @@ attaches to like. On this page: Ideal graphs · Assignment · +Weighted · +Directed · Coreness ### Core-periphery graphs {#core-periphery-graphs} @@ -660,17 +662,122 @@ chisq.test(as.factor(node_is_core(lawfirm)), ``` +::: {.callout} +**Try it yourself**: This section includes an interactive quiz in the live tutorial — run `run_tute()` at the R console to try it. +::: + +### Weighted cores {#weighted-cores} + +So far we have treated every tie as the same. +But in networks such as trade networks, airline networks, or communication networks, +the ties can carry very different weights. +Being part of the core is not just about having many ties, +but about having many *strong* ties. + +`ison_networkers` is a network of messages sent between researchers on an +electronic information exchange system. +It is both weighted, by the number of messages, and directed. +**Run the code to see how many messages the busiest ties carry.** + +```{r netw} +netw <- ison_networkers +summary(tie_weights(netw)) +``` + +For a weighted network, `{netrics}` uses the *rich-core* method by default. +It ranks the nodes by the total weight of their ties, not their count, +and walks down that ranking, +adding up the weight each node sends to the nodes ranked above it. +Added nodes that are still strongly tied to those already in the core will increase that total; +but a node that is not strongly tied to those already assigned to the core +will not add much, and the peak of this total signals the boundary of the core. +**Assign core membership on the weighted network, then on the same network +with its weights removed, and compare the two.** + + +Note that the two do not agree. +The weighted core assigns six researchers to the core, +while the binary core assigns 15. +That means nine researchers who have many correspondents nonetheless +do not exchange enough messages with the busiest others to survive the weighted cut. +Ignoring weights means the core is who exchange messages with the most others. +Reading the weights means it is the set of researchers who +exchange the most messages with each other. +Neither answer is the right one in general -- but you should know +which one you asked for. + +::: {.callout} +**Try it yourself**: This section includes an interactive quiz in the live tutorial — run `run_tute()` at the R console to try it. +::: + +::: {.callout} +**In brief**: On a weighted network, +`node_is_core()` and `node_by_core()` read the weights by default. +To ask the binary question instead, drop the weights first with +`to_unweighted()`. +::: + +### Directed cores {#directed-cores} + +Notice that at the very start of this section we wrote `to_undirected()`, +throwing away which partner named which. +For a core-periphery question that may be a real loss, +because tie direction can matter. +Indeed, a node could be core in one direction and peripheral in the other. +It might be core in *whom it reaches* yet peripheral in *who reaches it*. +An influencer might be followed by everyone yet follow no one; +an enthusiastic subscriber might follow everyone yet be followed by no one. +Let us keep the direction this time. +**Extract the friendship network again, without dropping direction.** + +```{r dlaw} +lawdir <- ison_lawfirm |> to_uniplex("friends") +is_directed(lawdir) +``` + +`node_by_core()` takes a `direction` argument, exactly as +`node_by_degree()` does. **Score the partners on the friendships they name, +then on the friendships they are named in, and see how far the two agree.** + + +The two correlate, but only moderately. +Partners sitting off the diagonal are effectively core in one direction but peripheral in the other, +meaning a single core-periphery split misdescribes their position. +`node_in_core(direction = "both")` keeps both answers, returning the four +categories that Elliott and colleagues distinguish: +**Core** for partners in both the out-core and the in-core, +**Sender** for partners in the out-core only, +**Receiver** for partners in the in-core only, +and **Periphery** for partners in neither. +**Assign the four categories and graph the network coloured by them.** + + +Most partners are peripheral, +but the remainder splits three ways rather than falling into one core. +The Receivers are named as friends more than they name others, +and the Senders the reverse. + ::: {.callout} **Try it yourself**: This section includes an interactive quiz in the live tutorial — run `run_tute()` at the R console to try it. ::: ### Coreness {#coreness} -An alternative route is to identify 'core' nodes -depending on their `r gloss("k-coreness","kcoreness")`. -In `{manynet}`, we can return nodes _k_-coreness -with `node_by_kcoreness()` instead of -the `node_is_core()` used for core-periphery. +`node_is_core()` classifies nodes binarily into core and periphery. +Two other functions grade the question instead, but they grade different things. + +The first is `node_by_core()`, which returns the continuous score used by `node_is_core()`. +It scores each node between 0 and 1 for how closely it resembles a typical core node. +**Score the law-firm network and compare the two.** + +```{r nodecoren0} +lawfirm |> + mutate_nodes(cness = node_by_core(), core = node_is_core()) |> + graphr(node_size = "cness", node_color = "core") +``` + +The second, `node_by_kcoreness()`, returns `r gloss("k-coreness","kcoreness")`. +This answers a different question: how deeply embedded does a node sit (in a network's core)? **Run the code to colour the law-firm network by each node's _k_-coreness.** ```{r nodecoren} @@ -679,25 +786,30 @@ lawfirm |> graphr(node_color = "ncn") ``` -Where `node_is_core()` forces a yes/no answer, -_k_-coreness grades how deep each node sits in the network: -a node with coreness _k_ survives even after all nodes of degree -less than _k_ have been successively peeled away. +A node with coreness _k_ survives even after all nodes of degree less than _k_ +have been successively peeled away. High-coreness nodes are thus embedded in a densely interlocked middle, -which matters for processes like diffusion — -what starts in a high _k_-core is far more likely to spread widely -than what starts among the peelable outer layers. +which matters for processes like diffusion. ::: {.callout} **Try it yourself**: This section includes an interactive quiz in the live tutorial — run `run_tute()` at the R console to try it. ::: ::: {.callout} -**In brief**: `create_core()` draws the ideal core-periphery -network; `node_is_core()` assigns each node to core or periphery, and -`net_by_core()` reports how well that bipartition actually fits the network -(1 = perfectly, ~0 = not at all). `node_by_kcoreness()` offers a graded -alternative, peeling the network into successively deeper _k_-cores. +**In brief**: `create_core()` draws the ideal core-periphery network; +`node_is_core()` assigns each node to core or periphery, +`node_in_core()` assigns each node into potentially more refined categories, +and `node_by_core()` returns the continuous score behind that assignment. +`node_by_kcoreness()` peels the network into successively deeper _k_-cores. +`net_by_core()` reports how well a bipartition actually fits the network +(1 = perfectly, ~0 = not at all). + +All of these take a `coreness` argument naming which method to use, +and a `direction` argument saying which ties to read. +By default the method follows the network: +the rich-core method for a weighted, directed, or two-mode network, +since it is the only one that reads those properties directly, +and the correlation method otherwise. ::: ## Hierarchy @@ -946,8 +1058,12 @@ Along the way, you have learned to use these functions: | `net_by_scalefree()` | power-law exponent fitted to the degree distribution | | `net_by_richclub()`, `net_by_assortativity()` | whether hubs interconnect, and whether like degrees attach to like | | `node_is_core()`, `node_in_core()` | assigns nodes to core/periphery (or core/semi-periphery/periphery) | +| `node_in_core(direction = "both")` | the four categories of a directed structure: Core, Sender, Receiver, Periphery | +| `node_by_core()` | continuous score, 0 to 1, for how core-like each node is | | `net_by_core()` | correlation of the network with an ideal core-periphery model | | `node_by_kcoreness()` | each node's _k_-coreness (depth in the network's successive cores) | +| `coreness_richcore()`, `coreness_correlation()` | the methods behind those functions; `coreness_richcore()` reads weights and direction | +| `tie_weights()`, `to_unweighted()` | read the tie weights, or drop them | | `net_x_hierarchy()` | Krackhardt's four graph-theoretic dimensions of hierarchy | | `net_by_connectedness()` | proportion of dyads that can reach each other | | `net_by_cohesion()`, `net_by_adhesion()` | minimum nodes / ties to remove to fragment the network | From 6e20bd7c28f90375245d3486e03e293d3552038c Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 19:04:31 +0200 Subject: [PATCH 47/68] Stop README advertising old functions --- README.Rmd | 8 ++++- README.md | 100 ++++++++++++++++++++++++++++++----------------------- 2 files changed, 63 insertions(+), 45 deletions(-) diff --git a/README.Rmd b/README.Rmd index de9980c..1a3aaff 100644 --- a/README.Rmd +++ b/README.Rmd @@ -12,8 +12,14 @@ knitr::opts_chunk$set( out.width = "100%" ) library(netrics) +# Renamed functions are kept as warning wrappers for one release, but the +# README lists the current API, so the wrappers in R/netrics-defunct.R are +# dropped rather than advertised alongside their replacements. That file is +# cleared at each minor release, so this list stays short. +defunct_fns <- c("node_by_coreness") +netrics_fns <- setdiff(ls("package:netrics"), defunct_fns) list_functions <- function(string){ - paste0("`", paste(paste0(ls("package:netrics")[grepl(string, ls("package:netrics"))], "()"), collapse = "`, `"), "`") + paste0("`", paste(paste0(netrics_fns[grepl(string, netrics_fns)], "()"), collapse = "`, `"), "`") } list_data <- function(string){ paste0("`", paste(paste0(ls("package:netrics")[grepl(string, ls("package:netrics"))]), collapse = "`, `"), "`") diff --git a/README.md b/README.md index 78c2576..33c06c6 100644 --- a/README.md +++ b/README.md @@ -84,42 +84,47 @@ maximum or minimum, respectively, node or tie according to some measure `{netrics}`’s `*_by_*()` functions offer numeric measures at the network, node, and tie level. These include: -- `net_by_adhesion()`, `net_by_assortativity()`, `net_by_balance()`, - `net_by_betweenness()`, `net_by_closeness()`, `net_by_cohesion()`, - `net_by_components()`, `net_by_congruency()`, - `net_by_connectedness()`, `net_by_core()`, `net_by_degree()`, +- `mode_by_betweenness()`, `mode_by_closeness()`, `mode_by_degree()`, + `mode_by_eigenvector()`, `mode_by_indegree()`, `mode_by_outdegree()`, + `net_by_adhesion()`, `net_by_assortativity()`, `net_by_balance()`, + `net_by_betweenness()`, `net_by_bipartivity()`, `net_by_closeness()`, + `net_by_cohesion()`, `net_by_compactness()`, `net_by_components()`, + `net_by_congruency()`, `net_by_connectedness()`, `net_by_core()`, + `net_by_cyclicality()`, `net_by_decay()`, `net_by_degree()`, `net_by_density()`, `net_by_diameter()`, `net_by_diversity()`, `net_by_efficiency()`, `net_by_eigenvector()`, `net_by_equivalency()`, `net_by_factions()`, `net_by_harmonic()`, `net_by_heterophily()`, - `net_by_homophily()`, `net_by_immunity()`, `net_by_indegree()`, - `net_by_independence()`, `net_by_infection_complete()`, - `net_by_infection_peak()`, `net_by_infection_total()`, - `net_by_length()`, `net_by_modularity()`, `net_by_outdegree()`, - `net_by_reach()`, `net_by_reciprocity()`, `net_by_recovery()`, - `net_by_reproduction()`, `net_by_richclub()`, `net_by_richness()`, - `net_by_scalefree()`, `net_by_smallworld()`, `net_by_spatial()`, - `net_by_strength()`, `net_by_toughness()`, `net_by_transitivity()`, + `net_by_homophily()`, `net_by_immunity()`, `net_by_inconsistency()`, + `net_by_indegree()`, `net_by_independence()`, + `net_by_infection_complete()`, `net_by_infection_peak()`, + `net_by_infection_total()`, `net_by_integration()`, `net_by_length()`, + `net_by_modularity()`, `net_by_outdegree()`, `net_by_reach()`, + `net_by_reciprocity()`, `net_by_recovery()`, `net_by_reproduction()`, + `net_by_richclub()`, `net_by_richness()`, `net_by_scalefree()`, + `net_by_smallworld()`, `net_by_spatial()`, `net_by_strength()`, + `net_by_toughness()`, `net_by_transitivity()`, `net_by_transmissibility()`, `net_by_upperbound()`, `net_by_waves()`, + `node_by_adopt_exposure()`, `node_by_adopt_recovery()`, `node_by_adopt_threshold()`, `node_by_adopt_time()`, `node_by_alpha()`, `node_by_authority()`, `node_by_betweenness()`, `node_by_bridges()`, `node_by_brokering_activity()`, `node_by_brokering_exclusivity()`, `node_by_closeness()`, - `node_by_constraint()`, `node_by_coreness()`, `node_by_deg()`, - `node_by_degree()`, `node_by_distance()`, `node_by_diversity()`, - `node_by_eccentricity()`, `node_by_efficiency()`, `node_by_effsize()`, - `node_by_eigenvector()`, `node_by_equivalency()`, - `node_by_exposure()`, `node_by_flow()`, `node_by_harmonic()`, + `node_by_constraint()`, `node_by_core()`, `node_by_decay()`, + `node_by_deg()`, `node_by_degree()`, `node_by_distance()`, + `node_by_diversity()`, `node_by_eccentricity()`, + `node_by_efficiency()`, `node_by_effsize()`, `node_by_eigenvector()`, + `node_by_equivalency()`, `node_by_flow()`, `node_by_harmonic()`, `node_by_heterophily()`, `node_by_hierarchy()`, `node_by_homophily()`, `node_by_hub()`, `node_by_indegree()`, `node_by_induced()`, - `node_by_information()`, `node_by_kcoreness()`, `node_by_leverage()`, - `node_by_multidegree()`, `node_by_neighbours_degree()`, - `node_by_outdegree()`, `node_by_pagerank()`, `node_by_posneg()`, - `node_by_power()`, `node_by_randomwalk()`, `node_by_reach()`, - `node_by_reciprocity()`, `node_by_recovery()`, `node_by_redundancy()`, - `node_by_richness()`, `node_by_stress()`, `node_by_subgraph()`, - `node_by_transitivity()`, `node_by_vitality()`, - `tie_by_betweenness()`, `tie_by_closeness()`, `tie_by_cohesion()`, - `tie_by_degree()`, `tie_by_eigenvector()` + `node_by_information()`, `node_by_integration()`, + `node_by_kcoreness()`, `node_by_leverage()`, `node_by_multidegree()`, + `node_by_neighbours_degree()`, `node_by_outdegree()`, + `node_by_pagerank()`, `node_by_posneg()`, `node_by_power()`, + `node_by_radiality()`, `node_by_randomwalk()`, `node_by_reach()`, + `node_by_reciprocity()`, `node_by_redundancy()`, `node_by_richness()`, + `node_by_stress()`, `node_by_subgraph()`, `node_by_transitivity()`, + `node_by_vitality()`, `tie_by_betweenness()`, `tie_by_closeness()`, + `tie_by_cohesion()`, `tie_by_degree()`, `tie_by_eigenvector()` The measures are organised into several broad categories, including: *Centrality*, *Cohesion*, *Hierarchy*, *Innovation* (structural holes), @@ -138,10 +143,11 @@ return a character vector, indicating e.g. that the first node is a member of group “A”, the second in group “B”, etc. - `node_in_adopter()`, `node_in_automorphic()`, `node_in_betweenness()`, - `node_in_brokering()`, `node_in_community()`, `node_in_component()`, - `node_in_core()`, `node_in_eigen()`, `node_in_equivalence()`, - `node_in_fluid()`, `node_in_greedy()`, `node_in_infomap()`, - `node_in_leiden()`, `node_in_louvain()`, `node_in_optimal()`, + `node_in_block()`, `node_in_brokering()`, `node_in_community()`, + `node_in_component()`, `node_in_core()`, `node_in_eigen()`, + `node_in_equivalence()`, `node_in_fluid()`, `node_in_greedy()`, + `node_in_infomap()`, `node_in_labels()`, `node_in_leiden()`, + `node_in_louvain()`, `node_in_motif()`, `node_in_optimal()`, `node_in_partition()`, `node_in_regular()`, `node_in_roulette()`, `node_in_spinglass()`, `node_in_strong()`, `node_in_structural()`, `node_in_walktrap()`, `node_in_weak()` @@ -167,10 +173,11 @@ frequency in various motifs. These include: - `net_x_brokerage()`, `net_x_change()`, `net_x_correlation()`, `net_x_dyad()`, `net_x_hazard()`, `net_x_hierarchy()`, - `net_x_mixed()`, `net_x_stability()`, `net_x_tetrad()`, - `net_x_triad()`, `node_x_brokerage()`, `node_x_dyad()`, - `node_x_exposure()`, `node_x_path()`, `node_x_tetrad()`, - `node_x_tie()`, `node_x_triad()` + `net_x_homophily()`, `net_x_mixed()`, `net_x_stability()`, + `net_x_tetrad()`, `net_x_triad()`, `node_x_alters()`, + `node_x_brokerage()`, `node_x_clique()`, `node_x_dyad()`, + `node_x_exposure()`, `node_x_path()`, `node_x_similarity()`, + `node_x_tetrad()`, `node_x_tie()`, `node_x_ties()`, `node_x_triad()` ## Analysis @@ -178,7 +185,9 @@ The functions in `{netrics}` are designed to answer a wide variety of analytic questions about networks. For example, you might want to know about: -- *Centrality*: `net_by_betweenness()`, `net_by_closeness()`, +- *Centrality*: `mode_by_betweenness()`, `mode_by_closeness()`, + `mode_by_degree()`, `mode_by_eigenvector()`, `mode_by_indegree()`, + `mode_by_outdegree()`, `net_by_betweenness()`, `net_by_closeness()`, `net_by_degree()`, `net_by_eigenvector()`, `net_by_indegree()`, `net_by_outdegree()`, `node_by_betweenness()`, `node_by_closeness()`, `node_by_degree()`, `node_by_eigenvector()`, `node_by_indegree()`, @@ -193,10 +202,12 @@ about: - *Hierarchy*: `net_by_connectedness()`, `net_by_efficiency()`, `net_by_reciprocity()`, `net_by_upperbound()`, `net_x_hierarchy()`, `node_by_efficiency()`, `node_by_hierarchy()`, `node_by_reciprocity()` -- *Topology*: `net_by_balance()`, `net_by_core()`, `net_by_factions()`, - `net_by_modularity()`, `net_by_richclub()`, `net_by_smallworld()`, - `node_by_coreness()`, `node_by_kcoreness()`, `node_in_core()`, - `node_is_core()`, `tie_is_imbalanced()` +- *Topology*: `coreness_correlation()`, `coreness_hub()`, + `coreness_richcore()`, `coreness_transition()`, `net_by_balance()`, + `net_by_core()`, `net_by_factions()`, `net_by_modularity()`, + `net_by_richclub()`, `net_by_smallworld()`, `node_by_core()`, + `node_by_kcoreness()`, `node_in_core()`, `node_is_core()`, + `tie_is_imbalanced()` - *Resilience*: `net_by_adhesion()`, `net_by_cohesion()`, `node_by_bridges()`, `node_is_cutpoint()`, `tie_by_cohesion()`, `tie_is_bridge()` @@ -206,12 +217,13 @@ about: `node_x_brokerage()` - *Diversity*: `net_by_assortativity()`, `net_by_diversity()`, `net_by_heterophily()`, `net_by_homophily()`, `net_by_richness()`, - `node_by_diversity()`, `node_by_heterophily()`, `node_by_homophily()`, - `node_by_richness()` + `net_x_homophily()`, `node_by_diversity()`, `node_by_heterophily()`, + `node_by_homophily()`, `node_by_richness()` - *Diffusion*: `net_by_infection_complete()`, `net_by_infection_peak()`, - `net_by_infection_total()`, `node_by_adopt_threshold()`, - `node_by_adopt_time()`, `node_by_exposure()`, `node_in_adopter()`, - `node_is_exposed()`, `node_is_infected()`, `node_x_exposure()` + `net_by_infection_total()`, `node_by_adopt_exposure()`, + `node_by_adopt_recovery()`, `node_by_adopt_threshold()`, + `node_by_adopt_time()`, `node_in_adopter()`, `node_is_exposed()`, + `node_is_infected()`, `node_x_exposure()` ## Installation From 2fd966e366820a4d475358a988a85354d9c52c96 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 20:06:20 +0200 Subject: [PATCH 48/68] Added `consensus=` to `node_in_community()` for combining partitions of all applicable algorithms --- NEWS.md | 3 + R/member_community.R | 237 ++++++++++++++++--------- man/member_community.Rd | 52 +++++- tests/testthat/test-member_community.R | 29 +++ 4 files changed, 226 insertions(+), 95 deletions(-) diff --git a/NEWS.md b/NEWS.md index c8f0fd5..f2e643d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -96,6 +96,9 @@ - `node_in_community()` considers only these algorithms when `k` is given - `k` also accepts `"silhouette"`, `"elbow"`, and `"strict"`, as in `node_in_equivalence()` - Note `k=` is now the second argument, so positional calls such as `node_in_louvain(x, 0.5)` must become `node_in_louvain(x, resolution = 0.5)` +- Added `consensus=` to `node_in_community()` for combining partitions of all applicable algorithms + - Runs each algorithm (stochastic ones `times`), then converges on how often each pair of nodes is grouped together + - `consensus = FALSE` default, and ignored where network small enough for `node_in_optimal()` - Renamed `node_by_coreness()` to `node_by_core()` - Fixed search starting points rather than random - Fixed it returning identical scores for a directed network and its reverse diff --git a/R/member_community.R b/R/member_community.R index 54e337e..70bf25e 100644 --- a/R/member_community.R +++ b/R/member_community.R @@ -137,98 +137,186 @@ apply_k <- function(k, Kmax, .data, at_k, default){ report_k(memb, k) } +# Helpers for combining algorithms #### + +# Assembles the algorithms applicable to this network. +# Both the selection and the consensus route draw on this, so the eligibility +# rules live in one place. +poss_algs <- function(k, .data){ + if(is.null(k)){ + if(manynet::net_nodes(.data) >= 100) + manynet::snet_info("Excluding {.fn node_in_optimal} because network rather large.") + poss <- c("node_in_infomap", + "node_in_spinglass", + "node_in_fluid", + "node_in_louvain", + "node_in_leiden", + "node_in_greedy", + "node_in_eigen", + "node_in_walktrap") + } else { + manynet::snet_info("Considering only those algorithms that accept {.arg k}.") + poss <- c("node_in_fluid", + "node_in_louvain", + "node_in_leiden", + "node_in_labels", + "node_in_partition", + "node_in_greedy", + "node_in_eigen", + "node_in_walktrap", + "node_in_betweenness") + } + exclude <- function(poss, these, why){ + hit <- intersect(poss, these) + if(length(hit)) manynet::snet_info("Excluding {.fn {hit}} because {why}.") + setdiff(poss, hit) + } + if(manynet::net_nodes(.data) >= 100) + poss <- exclude(poss, "node_in_betweenness", "network rather large") + if(!manynet::is_connected(.data)) + poss <- exclude(poss, c("node_in_spinglass", "node_in_fluid"), + "network unconnected") + 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") + poss +} + +# Runs one algorithm by name. +# Where `k` was requested the algorithm warns when it cannot reach it, which +# the caller reports once instead. +run_alg <- function(alg, .data, k, Kmax){ + if(is.null(k)) get(alg)(.data) else + suppressWarnings(get(alg)(.data, k = k, Kmax = Kmax)) +} + +# The algorithms that return a different partition on a second run. +STOCHASTIC_ALGS <- c("node_in_infomap", "node_in_spinglass", "node_in_fluid", + "node_in_louvain", "node_in_leiden", "node_in_labels") + +# The proportion of the given partitions in which each pair of nodes falls in +# the same group. `.to_cliques()` marks the pairs within one partition. +coassociation <- function(parts, n){ + out <- matrix(0, n, n) + for(p in parts) out <- out + .to_cliques(as.integer(factor(p))) + out/length(parts) +} + +# Combines many partitions into one, after Lancichinetti and Fortunato (2012). +# The algorithms are rerun on the co-association matrix until every pair either +# always or never shares a group, at which point the groups are its components. +consensus_memb <- function(.data, k, Kmax, times, threshold = 0.5, iter = 10){ + n <- manynet::net_nodes(.data) + gr <- .data + cons <- NULL + for(i in seq_len(iter)){ + algs <- poss_algs(k, gr) + parts <- unlist(lapply(algs, function(alg){ + reps <- if(alg %in% STOCHASTIC_ALGS) times else 1L + lapply(seq_len(reps), function(r) run_alg(alg, gr, k, Kmax)) + }), recursive = FALSE) + cons <- coassociation(parts, n) + cons[cons < threshold] <- 0 + if(all(cons == 0 | cons == 1)) break + gr <- igraph::graph_from_adjacency_matrix(cons, mode = "undirected", + weighted = TRUE, diag = FALSE) + } + igraph::components( + igraph::graph_from_adjacency_matrix(cons >= threshold, + mode = "undirected", + diag = FALSE))$membership +} + #' Memberships in communities #' @name member_community #' @description -#' `node_in_community()` runs through all available community detection algorithms -#' for a given network type, finds the algorithm that returns the -#' largest modularity score, and returns the corresponding membership -#' partition. +#' `node_in_community()` returns a single community partition of a network, +#' drawing on all the community detection algorithms available for that +#' type of network. +#' +#' By default it *selects* a partition. #' Where feasible (a small enough network), the optimal problem solving #' technique is used to ensure the maximal modularity partition. -#' For larger networks, it identifies the applicable algorithms and -#' finds the algorithm that maximises modularity and -#' returns that membership vector. +#' For larger networks, it identifies the applicable algorithms, +#' runs each of them, and returns the partition with the largest +#' modularity score. +#' +#' Where `consensus = TRUE` it *combines* the partitions instead. +#' Each applicable algorithm is run, the stochastic ones repeatedly, +#' and the algorithms are then rerun on how often each pair of nodes +#' is placed together until they agree. +#' This costs considerably more time than selection, +#' but does not rest the answer on a single run of a single algorithm. #' #' @template param_data #' @template param_k +#' @param consensus Logical, whether to combine the partitions of all the +#' applicable algorithms instead of selecting the one with the highest +#' modularity. By default `FALSE`, since combining them costs more time. +#' This argument is ignored on a network small enough for +#' `node_in_optimal()`, which already returns the maximum modularity +#' partition. +#' @param times An integer of how many times each stochastic algorithm is run +#' when `consensus = TRUE`. By default 20. Deterministic algorithms are run +#' once however this is set. #' @family community #' @template node_member +#' @references +#' ## On consensus community detection +#' Lancichinetti, Andrea, and Santo Fortunato. 2012. +#' "Consensus clustering in complex networks". +#' _Scientific Reports_ 2: 336. +#' \doi{10.1038/srep00336} +#' +#' Tagarelli, Andrea, Alessia Amelio, and Francesco Gullo. 2017. +#' "Ensemble-based Community Detection in Multilayer Networks". +#' _Data Mining and Knowledge Discovery_ 31: 1506-1543. +#' \doi{10.1007/s10618-017-0528-8} NULL #' @rdname member_community +#' @examples +#' node_in_community(ison_adolescents) #' @export -node_in_community <- function(.data, k = NULL, Kmax = 8L){ +node_in_community <- function(.data, k = NULL, Kmax = 8L, + consensus = FALSE, times = 20){ .data <- manynet::expect_nodes(.data) k <- check_k(k, .data) if(is.null(k) && manynet::net_nodes(.data)<100){ # 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}", + "already returns the maximum modularity partition.") manynet::snet_success("{.fn node_in_optimal} available and", "will return the highest modularity partition.") netrics::node_in_optimal(.data) + } else if(consensus){ + # `apply_k()` is not used here because its `at_k()` would rerun the whole + # consensus for every candidate number of groups. The constituent + # algorithms are given `k` instead, and the result merged only if it + # overshoots. + memb <- consensus_memb(.data, k, Kmax, times) + if(is.numeric(k) && length(unique(memb)) > k) + memb <- merge_to_k(.data, memb, k) + make_node_member(report_k(memb, k), .data) } else { - if(is.null(k)){ - manynet::snet_info("Excluding {.fn node_in_optimal} because network rather large.") - poss_algs <- c("node_in_infomap", - "node_in_spinglass", - "node_in_fluid", - "node_in_louvain", - "node_in_leiden", - "node_in_greedy", - "node_in_eigen", - "node_in_walktrap") - } else { - manynet::snet_info("Considering only those algorithms that accept {.arg k}.") - poss_algs <- c("node_in_fluid", - "node_in_louvain", - "node_in_leiden", - "node_in_labels", - "node_in_partition", - "node_in_greedy", - "node_in_eigen", - "node_in_walktrap", - "node_in_betweenness") - } - if(manynet::net_nodes(.data)>=100){ - notforlarge <- intersect(poss_algs, "node_in_betweenness") - if(length(notforlarge)){ - manynet::snet_info("Excluding {.fn {notforlarge}} because network rather large.") - poss_algs <- setdiff(poss_algs, notforlarge) - } - } - if(!manynet::is_connected(.data)){ - notforconnected <- intersect(poss_algs, c("node_in_spinglass", - "node_in_fluid")) - if(length(notforconnected)){ - manynet::snet_info("Excluding {.fn {notforconnected}} because network unconnected.") - poss_algs <- setdiff(poss_algs, notforconnected) - } - } - if(manynet::is_directed(.data)){ - notfordirected <- intersect(poss_algs, c("node_in_louvain", - "node_in_leiden", - "node_in_labels", - "node_in_partition", - "node_in_eigen")) - if(length(notfordirected)){ - manynet::snet_info("Excluding {.fn {notfordirected}} because network directed.") - poss_algs <- setdiff(poss_algs, notfordirected) - } - } - manynet::snet_info("Considering each of {.fn {poss_algs}}.") + poss <- poss_algs(k, .data) + manynet::snet_info("Considering each of {.fn {poss}}.") # `snet_progress_along()` returns nothing unless verbosity is "verbose", # so fall back to a plain sequence to keep the loop running when quiet - idx <- manynet::snet_progress_along(poss_algs) - if(length(idx) != length(poss_algs)) idx <- seq_along(poss_algs) + idx <- manynet::snet_progress_along(poss) + if(length(idx) != length(poss)) idx <- seq_along(poss) candidates <- lapply(idx, function(comm){ - memb <- if(is.null(k)) get(poss_algs[comm])(.data) else - suppressWarnings(get(poss_algs[comm])(.data, k = k, Kmax = Kmax)) + memb <- run_alg(poss[comm], .data, k, Kmax) mod <- net_by_modularity(.data, memb) list(memb, mod) }) mods <- unlist(sapply(candidates, "[", 2)) maxmod <- which.max(mods) - manynet::snet_success("{.fn {poss_algs[maxmod]}} returns the highest modularity ({round(mods[maxmod],3)}).") + manynet::snet_success("{.fn {poss[maxmod]}} returns the highest modularity ({round(mods[maxmod],3)}).") out <- candidates[[maxmod]][[1]] if(is.numeric(k) && length(unique(out)) != k) manynet::snet_warn("No available algorithm returns {k} communities here.", @@ -237,31 +325,6 @@ node_in_community <- function(.data, k = NULL, Kmax = 8L){ } } -# #' @rdname member_community_hier -# #' @section Ensemble: -# #' Ensemble-based community detection runs community detection -# #' algorithms over multilayer or multiplex networks. -# #' @references -# #' ## On ensemble-based community detection -# #' Tagarelli, Andrea, Alessia Amelio, and Francesco Gullo. 2017. -# #' "Ensemble-based Community Detection in Multilayer Networks". -# #' _Data Mining and Knowledge Discovery_, 31: 1506-1543. -# #' \doi{10.1007/s10618-017-0528-8} -# #' @examples -# #' node_in_ensemble(ison_adolescents) -# #' @export -# node_in_ensemble <- function(.data, linkage_constraint = TRUE){ -# if(missing(.data)) {expect_nodes(); .data <- .G()} -# clust <- igraph::cluster_walktrap(manynet::as_igraph(.data)) -# out <- clust$membership -# make_node_member(out, .data) -# out <- make_node_member(out, .data) -# attr(out, "hc") <- stats::as.hclust(clust, -# use.modularity = igraph::is_connected(.data)) -# attr(out, "k") <- max(clust$membership) -# out -# } - # Non-hierarchical community clustering #### #' Memberships in non-hierarchical communities diff --git a/man/member_community.Rd b/man/member_community.Rd index 08a7038..18efb52 100644 --- a/man/member_community.Rd +++ b/man/member_community.Rd @@ -5,7 +5,7 @@ \alias{node_in_community} \title{Memberships in communities} \usage{ -node_in_community(.data, k = NULL, Kmax = 8L) +node_in_community(.data, k = NULL, Kmax = 8L, consensus = FALSE, times = 20) } \arguments{ \item{.data}{A network object of class \code{stocnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. @@ -31,6 +31,17 @@ Otherwise ignored. Note that for \code{node_in_louvain()} and \code{node_in_leiden()} each candidate requires its own search over the resolution parameter, so a large \code{Kmax} is costly on large networks.} + +\item{consensus}{Logical, whether to combine the partitions of all the +applicable algorithms instead of selecting the one with the highest +modularity. By default \code{FALSE}, since combining them costs more time. +This argument is ignored on a network small enough for +\code{node_in_optimal()}, which already returns the maximum modularity +partition.} + +\item{times}{An integer of how many times each stochastic algorithm is run +when \code{consensus = TRUE}. By default 20. Deterministic algorithms are run +once however this is set.} } \value{ A \code{node_member} character vector the length of the nodes in the network, @@ -39,15 +50,40 @@ If the network is labelled, then the assignments will be labelled with the nodes' names. } \description{ -\code{node_in_community()} runs through all available community detection algorithms -for a given network type, finds the algorithm that returns the -largest modularity score, and returns the corresponding membership -partition. +\code{node_in_community()} returns a single community partition of a network, +drawing on all the community detection algorithms available for that +type of network. + +By default it \emph{selects} a partition. Where feasible (a small enough network), the optimal problem solving technique is used to ensure the maximal modularity partition. -For larger networks, it identifies the applicable algorithms and -finds the algorithm that maximises modularity and -returns that membership vector. +For larger networks, it identifies the applicable algorithms, +runs each of them, and returns the partition with the largest +modularity score. + +Where \code{consensus = TRUE} it \emph{combines} the partitions instead. +Each applicable algorithm is run, the stochastic ones repeatedly, +and the algorithms are then rerun on how often each pair of nodes +is placed together until they agree. +This costs considerably more time than selection, +but does not rest the answer on a single run of a single algorithm. +} +\examples{ +node_in_community(ison_adolescents) +} +\references{ +\subsection{On consensus community detection}{ + +Lancichinetti, Andrea, and Santo Fortunato. 2012. +"Consensus clustering in complex networks". +\emph{Scientific Reports} 2: 336. +\doi{10.1038/srep00336} + +Tagarelli, Andrea, Alessia Amelio, and Francesco Gullo. 2017. +"Ensemble-based Community Detection in Multilayer Networks". +\emph{Data Mining and Knowledge Discovery} 31: 1506-1543. +\doi{10.1007/s10618-017-0528-8} +} } \seealso{ Other community: diff --git a/tests/testthat/test-member_community.R b/tests/testthat/test-member_community.R index af9110c..728c7c3 100644 --- a/tests/testthat/test-member_community.R +++ b/tests/testthat/test-member_community.R @@ -123,3 +123,32 @@ test_that("node_in_walktrap passes steps to igraph", { expect_length(node_in_walktrap(ison_adolescents, steps = 8), net_nodes(ison_adolescents)) }) + +test_that("node_in_community consensus recovers planted components", { + set.seed(1234) + # four disjoint cliques, so every algorithm must agree on the partition + planted <- manynet::create_components(120, membership = rep(1:4, each = 30)) + res <- node_in_community(planted, consensus = TRUE, times = 2) + expect_s3_class(res, "node_member") + expect_length(res, manynet::net_nodes(planted)) + expect_equal(length(unique(res)), 4) + expect_equal(length(unique(paste(res, rep(1:4, each = 30)))), 4) +}) + +test_that("node_in_community consensus accepts k", { + set.seed(1234) + res <- node_in_community(ison_adolescents, k = 3, consensus = TRUE, times = 2) + expect_s3_class(res, "node_member") + expect_length(res, manynet::net_nodes(ison_adolescents)) + expect_equal(length(unique(res)), 3) +}) + +test_that("node_in_community ignores consensus where optimal is available", { + options(snet_verbosity = "verbose") + small <- manynet::create_ring(10) + # snet_info() signals a cli message, not an R condition + expect_message(node_in_community(small, consensus = TRUE), "Ignoring") + expect_equal(as.character(node_in_community(small, consensus = TRUE)), + as.character(node_in_optimal(small))) + options(snet_verbosity = "quiet") +}) From 3b1541ffb34093f61779a8dddff4ece527df10e1 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 20:24:44 +0200 Subject: [PATCH 49/68] Added Louvain and consensus examples to community tutorial --- inst/tutorials/netrics2/community.Rmd | 112 ++++++- inst/tutorials/netrics2/community.html | 421 ++++++++++++++++++++----- vignettes/articles/community.Rmd | 93 +++++- 3 files changed, 535 insertions(+), 91 deletions(-) diff --git a/inst/tutorials/netrics2/community.Rmd b/inst/tutorials/netrics2/community.Rmd index dbabad1..37aaff2 100644 --- a/inst/tutorials/netrics2/community.Rmd +++ b/inst/tutorials/netrics2/community.Rmd @@ -137,7 +137,8 @@ By the end of this tutorial, you should be able to: - [ ]   Count weak and strong components with `net_by_components()` and map their membership with `node_in_component()` - [ ]   Concentrate on a network's giant component with `to_giant()` and split it into factions with `node_in_partition()` - [ ]   Judge the fit of _any_ membership assignment with `net_by_modularity()` -- [ ]   Detect communities with the walktrap, edge-betweenness, and fast-greedy algorithms, and let `node_in_community()` choose among them for you +- [ ]   Detect communities with the walktrap, edge-betweenness, fast-greedy, and Louvain algorithms, and let `node_in_community()` choose among them for you +- [ ]   Tune how large a community must be to be kept separate with `resolution=`, and say why the resolution limit makes this necessary **Choose your own data**: The worked examples below use `ison_algebra`, `ison_southern_women`, and `irps_blogs`, @@ -1312,6 +1313,86 @@ See A Clauset, MEJ Newman, C Moore: Finding community structure in very large networks, https://arxiv.org/abs/cond-mat/0408187 +### Louvain {#louvain} + +The three algorithms so far are all rather slow on a large network. +Walktrap simulates random walks, +edge betweenness recomputes betweenness after every cut, +and fast greedy merges one pair of groups at a time. +The Louvain algorithm by contrast scales, +and is perhaps the most widely used community detection method today. + +It iterates in two phases until nothing improves: + +1. Each node is moved to whichever neighbouring group most improves modularity. +2. Each group is then contracted into a single node, + with ties between groups becoming weighted ties between these new nodes. + +The second phase is why it is so much faster. +After even only one pass the network is much smaller, +so the next pass is much cheaper, +and a network of millions of nodes collapses within a few rounds. + +```{r louv, exercise=TRUE, exercise.setup = "manip-fri"} +friend_lv <- node_in_louvain(friends) +friend_lv + +# The resolution argument changes how large a community has to be +# before it is worth keeping separate. +node_in_louvain(friends, resolution = 0.5) # fewer, larger communities +node_in_louvain(friends, resolution = 3) # more, smaller communities +``` + +```{r louv-hint-1, purl = FALSE} +# How does Louvain compare with the fast-greedy result on this network? +net_by_modularity(friends, friend_lv) +``` + +That `resolution` argument relates to a known weakness of modularity itself, +the `r gloss("resolution limit")`. +Below a certain size, a community can be missed entirely, +because merging it into a neighbour scores better than leaving it alone. +This happens for example when a small community is joined to the rest of the network +by a single tie. +The resolution argument lets you adjust the threshold for what counts as a community. +Raising the resolution privileges smaller communities, +and lowering it privileges larger ones. + +See VD Blondel, J-L Guillaume, R Lambiotte, E Lefebvre: +Fast unfolding of communities in large networks, +https://arxiv.org/abs/0803.0476 + +::: {.callout} +**Going further**: +Not every algorithm optimises modularity. +`node_in_infomap()` minimises the _map equation_ instead, +which asks how briefly (i.e. information theory) +you could describe the path of a random walker on the network. +A walker that stays inside a group for a long time can be described more briefly, +so good communities are the ones that compress well. +The practical difference is what happens to a weakly attached node: +Infomap tends to put it in a small community of its own, +whereas Louvain absorbs it into a neighbouring community. +Both do well on benchmark networks with a known answer +(Lancichinetti and Fortunato 2009), +so where the two disagree it is worth running both and comparing. +::: + +```{r louv-q, echo=FALSE, purl = FALSE} +question("Why is the Louvain algorithm so much faster than edge betweenness on a large network?", + answer("It only looks at a sample of the nodes", + message = "No -- Louvain visits every node. Its speed comes from shrinking the network, not from skipping parts of it."), + answer("It contracts each community into a single node and repeats on the smaller network", + correct = TRUE, + message = "That's right -- after the first pass the network is much smaller, so each further pass is much cheaper."), + answer("It stops as soon as it finds any improvement", + message = "No -- it keeps moving nodes until no single move improves modularity, and only then contracts."), + answer("It does not calculate modularity at all", + message = "No -- modularity is exactly what it maximises. Infomap is the one that optimises something else."), + random_answer_order = TRUE, + allow_retry = TRUE) +``` + ```{r comm-comp, echo=FALSE, purl = FALSE} question("What is the difference between communities and components?", answer("Communities and components are just different terms for the same thing", @@ -1344,6 +1425,20 @@ node_in_community(friends) It is important to name _which_ algorithm produced the membership assignment that is then subsequently analysed though. +Where the algorithms disagree, there is another option. +`node_in_community(consensus = TRUE)` does not pick a winner. +It runs every applicable algorithm, the stochastic ones several times over, +records how often each pair of nodes ends up together, +and then runs the algorithms again on that record until they agree. +The answer no longer rests on a single run of a single algorithm. +It costs considerably more time, which is why it is not the default, +and it is ignored on a network small enough for `node_in_optimal()`, +where the exact maximum is already available. + +```{r inconsensus, exercise = TRUE, exercise.setup = "manip-fri"} +node_in_community(irps_blogs, consensus = TRUE) +``` + ```{r alg-comp, echo=FALSE, purl = FALSE} question("Which algorithm provides the 'best' membership assignment here?", answer("Walktrap", @@ -1375,10 +1470,10 @@ question("Which algorithm provides the 'best' membership assignment here?", ::: {.callout} **Going further**: -The three algorithms treated here are only the start of the menagerie: -`{netrics}` also offers `node_in_louvain()` and `node_in_leiden()` -(fast multilevel modularity maximisers), -`node_in_infomap()` (information-flow based), +The four algorithms treated here are only the start of the menagerie: +`{netrics}` also offers `node_in_leiden()` +(a refinement of Louvain that avoids leaving a community internally +disconnected), `node_in_spinglass()` (simulated annealing), `node_in_fluid()` (fixed number of communities), and `node_in_eigen()` (spectral). @@ -1389,10 +1484,13 @@ See `?member_community` for the full list with definitions and references. **In brief**: Community detection algorithms cluster nodes by tie density: `node_in_walktrap()` via random walks, `node_in_betweenness()` divisively by cutting bridging ties, -and `node_in_greedy()` agglomeratively by modularity-improving merges. +`node_in_greedy()` agglomeratively by modularity-improving merges, +and `node_in_louvain()` by moving nodes and then contracting groups, +which is what makes it fast enough for a large network. `node_in_community()` surveys the applicable algorithms (exhaustively via `node_in_optimal()` on small networks) -and returns the assignment that maximises modularity — +and returns the assignment that maximises modularity, +or combines them all with `consensus = TRUE` — but always report which algorithm produced the result you analyse. ::: diff --git a/inst/tutorials/netrics2/community.html b/inst/tutorials/netrics2/community.html index 93ad18e..32bc99f 100644 --- a/inst/tutorials/netrics2/community.html +++ b/inst/tutorials/netrics2/community.html @@ -200,8 +200,11 @@

    Aims

  • +
  • Choose your own data: The worked examples below use ison_algebra, ison_southern_women, and @@ -1407,6 +1410,76 @@

    Fast greedy

    See A Clauset, MEJ Newman, C Moore: Finding community structure in very large networks, https://arxiv.org/abs/cond-mat/0408187

    + +
    +

    Louvain

    +

    The three algorithms so far are all rather slow on a large network. +Walktrap simulates random walks, edge betweenness recomputes betweenness +after every cut, and fast greedy merges one pair of groups at a time. +The Louvain algorithm by contrast scales, and is perhaps the most widely +used community detection method today.

    +

    It iterates in two phases until nothing improves:

    +
      +
    1. Each node is moved to whichever neighbouring group most improves +modularity.
    2. +
    3. Each group is then contracted into a single node, with ties between +groups becoming weighted ties between these new nodes.
    4. +
    +

    The second phase is why it is so much faster. After even only one +pass the network is much smaller, so the next pass is much cheaper, and +a network of millions of nodes collapses within a few rounds.

    +
    +
    friend_lv <- node_in_louvain(friends)
    +friend_lv
    +
    +# The resolution argument changes how large a community has to be
    +# before it is worth keeping separate.
    +node_in_louvain(friends, resolution = 0.5) # fewer, larger communities
    +node_in_louvain(friends, resolution = 3)   # more, smaller communities
    + +
    +
    +
    # How does Louvain compare with the fast-greedy result on this network?
    +net_by_modularity(friends, friend_lv)
    +
    +

    That resolution argument relates to a known weakness of +modularity itself, the resolution limit. Below a certain size, +a community can be missed entirely, because merging it into a neighbour +scores better than leaving it alone. This happens for example when a +small community is joined to the rest of the network by a single tie. +The resolution argument lets you adjust the threshold for what counts as +a community. Raising the resolution privileges smaller communities, and +lowering it privileges larger ones.

    +

    See VD Blondel, J-L Guillaume, R Lambiotte, E Lefebvre: Fast +unfolding of communities in large networks, https://arxiv.org/abs/0803.0476

    +
    +

    Going further: +Not every algorithm optimises modularity. node_in_infomap() +minimises the map equation instead, which asks how briefly +(i.e. information theory) you could describe the path of a random walker +on the network. A walker that stays inside a group for a long time can +be described more briefly, so good communities are the ones that +compress well. The practical difference is what happens to a weakly +attached node: Infomap tends to put it in a small community of its own, +whereas Louvain absorbs it into a neighbouring community. Both do well +on benchmark networks with a known answer (Lancichinetti and Fortunato +2009), so where the two disagree it is worth running both and +comparing.

    +
    +
    +
    +
    +
    +
    + +
    +
    @@ -1435,6 +1508,21 @@

    Detecting communities

    It is important to name which algorithm produced the membership assignment that is then subsequently analysed though.

    +

    Where the algorithms disagree, there is another option. +node_in_community(consensus = TRUE) does not pick a winner. +It runs every applicable algorithm, the stochastic ones several times +over, records how often each pair of nodes ends up together, and then +runs the algorithms again on that record until they agree. The answer no +longer rests on a single run of a single algorithm. It costs +considerably more time, which is why it is not the default, and it is +ignored on a network small enough for node_in_optimal(), +where the exact maximum is already available.

    +
    +
    node_in_community(irps_blogs, consensus = TRUE)
    + +
    @@ -1445,11 +1533,10 @@

    Detecting communities

    Going further: -The three algorithms treated here are only the start of the menagerie: -{netrics} also offers node_in_louvain() and -node_in_leiden() (fast multilevel modularity maximisers), -node_in_infomap() (information-flow based), -node_in_spinglass() (simulated annealing), +The four algorithms treated here are only the start of the menagerie: +{netrics} also offers node_in_leiden() (a +refinement of Louvain that avoids leaving a community internally +disconnected), node_in_spinglass() (simulated annealing), node_in_fluid() (fixed number of communities), and node_in_eigen() (spectral). See ?member_community for the full list with definitions and @@ -1460,12 +1547,14 @@

    Detecting communities

    Community detection algorithms cluster nodes by tie density: node_in_walktrap() via random walks, node_in_betweenness() divisively by cutting bridging ties, -and node_in_greedy() agglomeratively by -modularity-improving merges. node_in_community() surveys -the applicable algorithms (exhaustively via -node_in_optimal() on small networks) and returns the -assignment that maximises modularity — but always report which algorithm -produced the result you analyse.

    +node_in_greedy() agglomeratively by modularity-improving +merges, and node_in_louvain() by moving nodes and then +contracting groups, which is what makes it fast enough for a large +network. node_in_community() surveys the applicable +algorithms (exhaustively via node_in_optimal() on small +networks) and returns the assignment that maximises modularity, or +combines them all with consensus = TRUE — but always report +which algorithm produced the result you analyse.

    @@ -2094,15 +2183,15 @@

    Glossary

    @@ -2253,11 +2342,11 @@

    Glossary

    @@ -2478,31 +2567,31 @@

    Glossary

    @@ -2875,31 +2964,31 @@

    Glossary

    @@ -2921,13 +3010,13 @@

    Glossary

    @@ -3016,19 +3105,19 @@

    Glossary

    @@ -3119,23 +3208,23 @@

    Glossary

    @@ -3547,25 +3636,25 @@

    Glossary

    @@ -3655,15 +3744,15 @@

    Glossary

    @@ -4123,20 +4212,129 @@

    Glossary

    ))) + + + + + + + + - - + - + + + + + + - + + - - +
    diff --git a/vignettes/articles/community.Rmd b/vignettes/articles/community.Rmd index 95ff9c8..0afbc59 100644 --- a/vignettes/articles/community.Rmd +++ b/vignettes/articles/community.Rmd @@ -130,7 +130,8 @@ By the end of this tutorial, you should be able to: - [ ]   Count weak and strong components with `net_by_components()` and map their membership with `node_in_component()` - [ ]   Concentrate on a network's giant component with `to_giant()` and split it into factions with `node_in_partition()` - [ ]   Judge the fit of _any_ membership assignment with `net_by_modularity()` -- [ ]   Detect communities with the walktrap, edge-betweenness, and fast-greedy algorithms, and let `node_in_community()` choose among them for you +- [ ]   Detect communities with the walktrap, edge-betweenness, fast-greedy, and Louvain algorithms, and let `node_in_community()` choose among them for you +- [ ]   Tune how large a community must be to be kept separate with `resolution=`, and say why the resolution limit makes this necessary **Choose your own data**: The worked examples below use `ison_algebra`, `ison_southern_women`, and `irps_blogs`, @@ -832,6 +833,67 @@ See A Clauset, MEJ Newman, C Moore: Finding community structure in very large networks, https://arxiv.org/abs/cond-mat/0408187 +### Louvain {#louvain} + +The three algorithms so far are all rather slow on a large network. +Walktrap simulates random walks, +edge betweenness recomputes betweenness after every cut, +and fast greedy merges one pair of groups at a time. +The Louvain algorithm by contrast scales, +and is perhaps the most widely used community detection method today. + +It iterates in two phases until nothing improves: + +1. Each node is moved to whichever neighbouring group most improves modularity. +2. Each group is then contracted into a single node, + with ties between groups becoming weighted ties between these new nodes. + +The second phase is why it is so much faster. +After even only one pass the network is much smaller, +so the next pass is much cheaper, +and a network of millions of nodes collapses within a few rounds. + +```{r louv} +friend_lv <- node_in_louvain(friends) +friend_lv + +# The resolution argument changes how large a community has to be +# before it is worth keeping separate. +node_in_louvain(friends, resolution = 0.5) # fewer, larger communities +node_in_louvain(friends, resolution = 3) # more, smaller communities +``` + + +That `resolution` argument relates to a known weakness of modularity itself, +the `r gloss("resolution limit")`. +Below a certain size, a community can be missed entirely, +because merging it into a neighbour scores better than leaving it alone. +This happens for example when a small community is joined to the rest of the network +by a single tie. +The resolution argument lets you adjust the threshold for what counts as a community. +Raising the resolution privileges smaller communities, +and lowering it privileges larger ones. + +See VD Blondel, J-L Guillaume, R Lambiotte, E Lefebvre: +Fast unfolding of communities in large networks, +https://arxiv.org/abs/0803.0476 + +::: {.callout} +**Going further**: +Not every algorithm optimises modularity. +`node_in_infomap()` minimises the _map equation_ instead, +which asks how briefly (i.e. information theory) +you could describe the path of a random walker on the network. +A walker that stays inside a group for a long time can be described more briefly, +so good communities are the ones that compress well. +The practical difference is what happens to a weakly attached node: +Infomap tends to put it in a small community of its own, +whereas Louvain absorbs it into a neighbouring community. +Both do well on benchmark networks with a known answer +(Lancichinetti and Fortunato 2009), +so where the two disagree it is worth running both and comparing. +::: + ::: {.callout} **Try it yourself**: This section includes an interactive quiz in the live tutorial — run `run_tute()` at the R console to try it. ::: @@ -855,16 +917,30 @@ node_in_community(friends) It is important to name _which_ algorithm produced the membership assignment that is then subsequently analysed though. +Where the algorithms disagree, there is another option. +`node_in_community(consensus = TRUE)` does not pick a winner. +It runs every applicable algorithm, the stochastic ones several times over, +records how often each pair of nodes ends up together, +and then runs the algorithms again on that record until they agree. +The answer no longer rests on a single run of a single algorithm. +It costs considerably more time, which is why it is not the default, +and it is ignored on a network small enough for `node_in_optimal()`, +where the exact maximum is already available. + +```{r inconsensus} +node_in_community(irps_blogs, consensus = TRUE) +``` + ::: {.callout} **Try it yourself**: This section includes an interactive quiz in the live tutorial — run `run_tute()` at the R console to try it. ::: ::: {.callout} **Going further**: -The three algorithms treated here are only the start of the menagerie: -`{netrics}` also offers `node_in_louvain()` and `node_in_leiden()` -(fast multilevel modularity maximisers), -`node_in_infomap()` (information-flow based), +The four algorithms treated here are only the start of the menagerie: +`{netrics}` also offers `node_in_leiden()` +(a refinement of Louvain that avoids leaving a community internally +disconnected), `node_in_spinglass()` (simulated annealing), `node_in_fluid()` (fixed number of communities), and `node_in_eigen()` (spectral). @@ -875,10 +951,13 @@ See `?member_community` for the full list with definitions and references. **In brief**: Community detection algorithms cluster nodes by tie density: `node_in_walktrap()` via random walks, `node_in_betweenness()` divisively by cutting bridging ties, -and `node_in_greedy()` agglomeratively by modularity-improving merges. +`node_in_greedy()` agglomeratively by modularity-improving merges, +and `node_in_louvain()` by moving nodes and then contracting groups, +which is what makes it fast enough for a large network. `node_in_community()` surveys the applicable algorithms (exhaustively via `node_in_optimal()` on small networks) -and returns the assignment that maximises modularity — +and returns the assignment that maximises modularity, +or combines them all with `consensus = TRUE` — but always report which algorithm produced the result you analyse. ::: From 2126ac5c34c2e0877906fd9bd5d1ed343c16c2c7 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 20:33:29 +0200 Subject: [PATCH 50/68] Renamed coreness_richcore to coreness_rich --- NAMESPACE | 2 +- NEWS.md | 5 +- R/class_metrics.R | 6 +- R/member_core.R | 4 +- R/method_coreness.R | 22 ++- README.md | 2 +- inst/tutorials/netrics4/topology.Rmd | 2 +- inst/tutorials/netrics4/topology.html | 228 +++++++++++++------------- man-roxygen/param_coreness.R | 4 +- man/defunct.Rd | 4 +- man/mark_core.Rd | 4 +- man/measure_core.Rd | 4 +- man/measure_fit.Rd | 4 +- man/member_core.Rd | 4 +- man/method_coreness.Rd | 20 ++- tests/testthat/helper-contract.R | 2 +- tests/testthat/test-member_core.R | 6 +- vignettes/articles/topology.Rmd | 2 +- 18 files changed, 171 insertions(+), 154 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 65304ff..b0e27dd 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -5,7 +5,7 @@ export(cluster_cosine) export(cluster_hierarchical) export(coreness_correlation) export(coreness_hub) -export(coreness_richcore) +export(coreness_rich) export(coreness_transition) export(k_elbow) export(k_gap) diff --git a/NEWS.md b/NEWS.md index f2e643d..c76d18b 100644 --- a/NEWS.md +++ b/NEWS.md @@ -104,7 +104,7 @@ - Fixed it returning identical scores for a directed network and its reverse - Fixed it erroring on two-mode networks whose modes are of unequal size - Improved `node_in_core()` - - Renamed `centrality=` to `coreness=`: `"richcore"` default for weighted, directed, or two-mode networks, + - Renamed `centrality=` to `coreness=`: `"rich"` default for weighted, directed, or two-mode networks, `"correlation"` otherwise - Adds `direction=` for directed networks, adding `"Sender"` for core out-ties and periphery in-ties and `"Receiver"` for core in-ties and periphery out-ties @@ -140,7 +140,8 @@ - Note `regularity_rege()` is degenerate on unweighted connected networks, where it warns - Added coreness methods for core-periphery analysis, each returning mark, member, and measure - `coreness_correlation()` is Borgatti and Everett's continuous model, fixed to exclude self-ties and to start its search from the degree ordering rather than from a flat vector, where the correlation is undefined - - `coreness_richcore()` is Ma and Mondragon's rich-core, which reads tie weights and tie direction directly, and is the only method that runs on a two-mode network + - `coreness_rich()` is Ma and Mondragon's rich-core, which reads tie weights and tie direction directly, and is the only method that runs on a two-mode network + - Note this is not the rich club that `net_by_richclub()` measures: a rich core need not be densely tied, and needs no null model - `coreness_transition()` is Rombach and colleagues' core score, aggregated over a grid of boundary sharpness and core size - `coreness_hub()` is Elliott and colleagues' directed core-periphery, distinguishing an out-core from an in-core diff --git a/R/class_metrics.R b/R/class_metrics.R index 91f0bef..33358ca 100644 --- a/R/class_metrics.R +++ b/R/class_metrics.R @@ -183,7 +183,7 @@ resolve_coreness <- function(coreness, centrality = NULL) { coreness } -CORENESSES <- c("correlation", "richcore", "transition", "hub") +CORENESSES <- c("correlation", "rich", "transition", "hub") # Chooses the method when the user has not, and says which it chose. No one # method suits every network: the correlation and transition methods compare @@ -194,7 +194,7 @@ check_coreness <- function(.data, coreness = NULL) { if(is.null(coreness)) { coreness <- if(manynet::is_twomode(.data) || manynet::is_weighted(.data) || - manynet::is_directed(.data)) "richcore" else "correlation" + manynet::is_directed(.data)) "rich" else "correlation" manynet::snet_info("Calculating coreness using", "{.fn coreness_{coreness}}.") } else coreness <- match.arg(coreness, CORENESSES) @@ -206,7 +206,7 @@ check_coreness <- function(.data, coreness = NULL) { run_coreness <- function(.data, coreness, direction = "all") { switch(coreness, correlation = coreness_correlation(.data, direction = direction), - richcore = coreness_richcore(.data, direction = direction), + rich = coreness_rich(.data, direction = direction), transition = coreness_transition(.data, direction = direction), hub = coreness_hub(.data, direction = direction)) } diff --git a/R/member_core.R b/R/member_core.R index d433f8e..0834354 100644 --- a/R/member_core.R +++ b/R/member_core.R @@ -152,9 +152,9 @@ NULL #' One of "bins" (equal-width bins), "quantiles" (quantile-based bins), #' or "kmeans" (k-means clustering). Default is "bins". #' @param coreness Which method to use to calculate nodes' coreness. -#' One of "correlation", "richcore", "transition", or "hub"; +#' One of "correlation", "rich", "transition", or "hub"; #' see [method_coreness] for what each does. -#' By default NULL, which uses "richcore" for a weighted, directed, or +#' By default NULL, which uses "rich" for a weighted, directed, or #' two-mode network, since it is the only method that reads those properties #' directly, and "correlation" otherwise. #' @param direction One of "all" (the default), "out", "in", or "both". diff --git a/R/method_coreness.R b/R/method_coreness.R index 42ef974..ff1413a 100644 --- a/R/method_coreness.R +++ b/R/method_coreness.R @@ -9,14 +9,14 @@ #' #' - `coreness_correlation()` fits the network to an ideal core-periphery #' pattern by correlation. -#' - `coreness_richcore()` ranks nodes by strength and cuts where the tie +#' - `coreness_rich()` ranks nodes by strength and cuts where the tie #' weight to higher-ranked neighbours peaks. #' - `coreness_transition()` scores nodes with a transition function whose #' sharpness and core size are free parameters. #' - `coreness_hub()` scores nodes by how well they send to and receive from #' the core, which lets core and periphery differ by tie direction. #' -#' They differ in what they can use. `coreness_richcore()` and +#' They differ in what they can use. `coreness_rich()` and #' `coreness_hub()` read tie direction and tie weights directly. #' `coreness_correlation()` and `coreness_transition()` compare the network #' against a symmetric ideal, so they symmetrise a directed network first @@ -167,7 +167,7 @@ NULL #' #' The search has one free value per node, so its cost grows quickly with #' the size of the network. On a large network, lower `starts`, or use -#' [coreness_richcore()], which needs no search at all. +#' [coreness_rich()], which needs no search at all. #' @param starts Integer number of starting points for the search, #' at most 9. By default 5. #' The starting points are fixed rather than random, so that two calls on @@ -182,7 +182,7 @@ coreness_correlation <- function(.data, direction = c("all","out","in"), if(manynet::is_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_richcore} instead.") + "not. Try {.fn coreness_rich} instead.") .core_symmetrise_info(.data, "coreness_correlation") mat <- .core_matrix(.data, "all") n <- nrow(mat) @@ -228,10 +228,18 @@ coreness_correlation <- function(.data, direction = c("all","out","in"), #' set that receives, \eqn{\sigma^+} never rises, and the method returns a #' core of one or two nodes. Use [coreness_hub()] for that structure, which #' keeps the two sets apart rather than trying to merge them. +#' +#' A rich core is not a rich club, which is why this method is not named for +#' one. A rich club requires the high-degree nodes to be densely tied to one +#' another, and [net_by_richclub()] measures that density. A rich core only +#' marks the rank at which nodes stop linking upward, so a network can have +#' a rich core whose members are not densely tied. The rich core also needs +#' no null model, where the rich-club coefficient does, since that +#' coefficient rises with degree even in a random network. #' @examples -#' coreness_richcore(ison_networkers) +#' coreness_rich(ison_networkers) #' @export -coreness_richcore <- 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) @@ -287,7 +295,7 @@ coreness_transition <- function(.data, direction = c("all","out","in"), if(manynet::is_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_richcore} instead.") + "not. Try {.fn coreness_rich} instead.") .core_symmetrise_info(.data, "coreness_transition") mat <- .core_matrix(.data, "all") n <- nrow(mat) diff --git a/README.md b/README.md index 33c06c6..dce5819 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ about: `net_by_reciprocity()`, `net_by_upperbound()`, `net_x_hierarchy()`, `node_by_efficiency()`, `node_by_hierarchy()`, `node_by_reciprocity()` - *Topology*: `coreness_correlation()`, `coreness_hub()`, - `coreness_richcore()`, `coreness_transition()`, `net_by_balance()`, + `coreness_rich()`, `coreness_transition()`, `net_by_balance()`, `net_by_core()`, `net_by_factions()`, `net_by_modularity()`, `net_by_richclub()`, `net_by_smallworld()`, `node_by_core()`, `node_by_kcoreness()`, `node_in_core()`, `node_is_core()`, diff --git a/inst/tutorials/netrics4/topology.Rmd b/inst/tutorials/netrics4/topology.Rmd index d166f95..daffea7 100644 --- a/inst/tutorials/netrics4/topology.Rmd +++ b/inst/tutorials/netrics4/topology.Rmd @@ -1431,7 +1431,7 @@ Along the way, you have learned to use these functions: | `node_by_core()` | continuous score, 0 to 1, for how core-like each node is | | `net_by_core()` | correlation of the network with an ideal core-periphery model | | `node_by_kcoreness()` | each node's _k_-coreness (depth in the network's successive cores) | -| `coreness_richcore()`, `coreness_correlation()` | the methods behind those functions; `coreness_richcore()` reads weights and direction | +| `coreness_rich()`, `coreness_correlation()` | the methods behind those functions; `coreness_rich()` reads weights and direction | | `tie_weights()`, `to_unweighted()` | read the tie weights, or drop them | | `net_x_hierarchy()` | Krackhardt's four graph-theoretic dimensions of hierarchy | | `net_by_connectedness()` | proportion of dyads that can reach each other | diff --git a/inst/tutorials/netrics4/topology.html b/inst/tutorials/netrics4/topology.html index d18fdb1..f9f0c62 100644 --- a/inst/tutorials/netrics4/topology.html +++ b/inst/tutorials/netrics4/topology.html @@ -1623,9 +1623,9 @@

    Summary

    cores) -coreness_richcore(), +coreness_rich(), coreness_correlation() -the methods behind those functions; coreness_richcore() +the methods behind those functions; coreness_rich() reads weights and direction @@ -1911,34 +1911,34 @@

    Glossary

    encouragement = c("Good effort")) - - - + + @@ -2945,11 +2945,11 @@

    Glossary

    @@ -3217,30 +3217,30 @@

    Glossary

    @@ -3324,23 +3324,23 @@

    Glossary

    @@ -3481,28 +3481,28 @@

    Glossary

    @@ -3706,21 +3706,21 @@

    Glossary

    @@ -3863,23 +3863,23 @@

    Glossary

    @@ -3928,34 +3928,34 @@

    Glossary

    @@ -4092,22 +4092,22 @@

    Glossary

    @@ -4186,18 +4186,18 @@

    Glossary

    @@ -4272,19 +4272,19 @@

    Glossary

    @@ -4630,7 +4630,7 @@

    Glossary

    diff --git a/man-roxygen/param_coreness.R b/man-roxygen/param_coreness.R index 11cb29e..3a47685 100644 --- a/man-roxygen/param_coreness.R +++ b/man-roxygen/param_coreness.R @@ -1,7 +1,7 @@ #' @param coreness Which method to use to calculate nodes' coreness. -#' One of "correlation", "richcore", "transition", or "hub"; +#' One of "correlation", "rich", "transition", or "hub"; #' see [method_coreness] for what each does. -#' By default NULL, which uses "richcore" for a weighted, directed, or +#' By default NULL, which uses "rich" for a weighted, directed, or #' two-mode network, since it is the only method that reads those properties #' directly, and "correlation" otherwise. #' @param direction One of "all" (the default), "out", or "in". diff --git a/man/defunct.Rd b/man/defunct.Rd index cbda4fb..d30b6ad 100644 --- a/man/defunct.Rd +++ b/man/defunct.Rd @@ -13,9 +13,9 @@ 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{coreness}{Which method to use to calculate nodes' coreness. -One of "correlation", "richcore", "transition", or "hub"; +One of "correlation", "rich", "transition", or "hub"; see \link{method_coreness} for what each does. -By default NULL, which uses "richcore" for a weighted, directed, or +By default NULL, which uses "rich" for a weighted, directed, or two-mode network, since it is the only method that reads those properties directly, and "correlation" otherwise.} diff --git a/man/mark_core.Rd b/man/mark_core.Rd index dd0a777..9312ad7 100644 --- a/man/mark_core.Rd +++ b/man/mark_core.Rd @@ -18,9 +18,9 @@ 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{coreness}{Which method to use to calculate nodes' coreness. -One of "correlation", "richcore", "transition", or "hub"; +One of "correlation", "rich", "transition", or "hub"; see \link{method_coreness} for what each does. -By default NULL, which uses "richcore" for a weighted, directed, or +By default NULL, which uses "rich" for a weighted, directed, or two-mode network, since it is the only method that reads those properties directly, and "correlation" otherwise.} diff --git a/man/measure_core.Rd b/man/measure_core.Rd index 96ef6e5..cd38c6c 100644 --- a/man/measure_core.Rd +++ b/man/measure_core.Rd @@ -16,9 +16,9 @@ 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{coreness}{Which method to use to calculate nodes' coreness. -One of "correlation", "richcore", "transition", or "hub"; +One of "correlation", "rich", "transition", or "hub"; see \link{method_coreness} for what each does. -By default NULL, which uses "richcore" for a weighted, directed, or +By default NULL, which uses "rich" for a weighted, directed, or two-mode network, since it is the only method that reads those properties directly, and "correlation" otherwise.} diff --git a/man/measure_fit.Rd b/man/measure_fit.Rd index 48af706..5949387 100644 --- a/man/measure_fit.Rd +++ b/man/measure_fit.Rd @@ -41,9 +41,9 @@ member of the core and the most core-like member of the periphery. square root of the size of the core, thus penalising large cores.} \item{coreness}{Which method to use to calculate nodes' coreness. -One of "correlation", "richcore", "transition", or "hub"; +One of "correlation", "rich", "transition", or "hub"; see \link{method_coreness} for what each does. -By default NULL, which uses "richcore" for a weighted, directed, or +By default NULL, which uses "rich" for a weighted, directed, or two-mode network, since it is the only method that reads those properties directly, and "correlation" otherwise.} diff --git a/man/member_core.Rd b/man/member_core.Rd index d5cd621..7f70916 100644 --- a/man/member_core.Rd +++ b/man/member_core.Rd @@ -26,9 +26,9 @@ One of "bins" (equal-width bins), "quantiles" (quantile-based bins), or "kmeans" (k-means clustering). Default is "bins".} \item{coreness}{Which method to use to calculate nodes' coreness. -One of "correlation", "richcore", "transition", or "hub"; +One of "correlation", "rich", "transition", or "hub"; see \link{method_coreness} for what each does. -By default NULL, which uses "richcore" for a weighted, directed, or +By default NULL, which uses "rich" for a weighted, directed, or two-mode network, since it is the only method that reads those properties directly, and "correlation" otherwise.} diff --git a/man/method_coreness.Rd b/man/method_coreness.Rd index ad16d67..2bcbce4 100644 --- a/man/method_coreness.Rd +++ b/man/method_coreness.Rd @@ -3,14 +3,14 @@ \name{method_coreness} \alias{method_coreness} \alias{coreness_correlation} -\alias{coreness_richcore} +\alias{coreness_rich} \alias{coreness_transition} \alias{coreness_hub} \title{Methods for calculating coreness} \usage{ coreness_correlation(.data, direction = c("all", "out", "in"), starts = 5L) -coreness_richcore(.data, direction = c("all", "out", "in")) +coreness_rich(.data, direction = c("all", "out", "in")) coreness_transition( .data, @@ -60,7 +60,7 @@ continuous coreness score and a core/periphery split that \itemize{ \item \code{coreness_correlation()} fits the network to an ideal core-periphery pattern by correlation. -\item \code{coreness_richcore()} ranks nodes by strength and cuts where the tie +\item \code{coreness_rich()} ranks nodes by strength and cuts where the tie weight to higher-ranked neighbours peaks. \item \code{coreness_transition()} scores nodes with a transition function whose sharpness and core size are free parameters. @@ -68,7 +68,7 @@ sharpness and core size are free parameters. the core, which lets core and periphery differ by tie direction. } -They differ in what they can use. \code{coreness_richcore()} and +They differ in what they can use. \code{coreness_rich()} and \code{coreness_hub()} read tie direction and tie weights directly. \code{coreness_correlation()} and \code{coreness_transition()} compare the network against a symmetric ideal, so they symmetrise a directed network first @@ -94,7 +94,7 @@ use \code{\link[manynet:to_unweighted]{manynet::to_unweighted()}} first. The search has one free value per node, so its cost grows quickly with the size of the network. On a large network, lower \code{starts}, or use -\code{\link[=coreness_richcore]{coreness_richcore()}}, which needs no search at all. +\code{\link[=coreness_rich]{coreness_rich()}}, which needs no search at all. } \section{Rich-core}{ @@ -118,6 +118,14 @@ Where a directed network instead has one set that sends and a different set that receives, \eqn{\sigma^+} never rises, and the method returns a core of one or two nodes. Use \code{\link[=coreness_hub]{coreness_hub()}} for that structure, which keeps the two sets apart rather than trying to merge them. + +A rich core is not a rich club, which is why this method is not named for +one. A rich club requires the high-degree nodes to be densely tied to one +another, and \code{\link[=net_by_richclub]{net_by_richclub()}} measures that density. A rich core only +marks the rank at which nodes stop linking upward, so a network can have +a rich core whose members are not densely tied. The rich core also needs +no null model, where the rich-club coefficient does, since that +coefficient rises with degree even in a random network. } \section{Transition}{ @@ -154,7 +162,7 @@ the two scores, and the core is the set of nodes in both. \examples{ coreness_correlation(ison_adolescents) -coreness_richcore(ison_networkers) +coreness_rich(ison_networkers) coreness_transition(ison_adolescents) coreness_hub(ison_networkers) } diff --git a/tests/testthat/helper-contract.R b/tests/testthat/helper-contract.R index 86025de..81fb0b8 100644 --- a/tests/testthat/helper-contract.R +++ b/tests/testthat/helper-contract.R @@ -185,7 +185,7 @@ measure_rosters <- list( node_by_core = list() ), core_method_node = list( - node_by_core = list(coreness = "richcore") + node_by_core = list(coreness = "rich") ), brokerage_node = list( node_by_brokering_activity = list(membership = "Discipline"), diff --git a/tests/testthat/test-member_core.R b/tests/testthat/test-member_core.R index 42e6e9d..5c47ca2 100644 --- a/tests/testthat/test-member_core.R +++ b/tests/testthat/test-member_core.R @@ -62,7 +62,7 @@ test_that("node_by_core works on a two-mode network", { }) test_that("every coreness method returns a coreness and a core", { - for (fn in list(coreness_correlation, coreness_richcore, + for (fn in list(coreness_correlation, coreness_rich, coreness_transition, coreness_hub)) { out <- fn(ison_adolescents) expect_length(out$coreness, 8) @@ -101,8 +101,8 @@ test_that("the methods recover a planted core", { m[j, i] <- 1 } g <- as_igraph(m, twomode = FALSE) - expect_equal(which(coreness_richcore(g)$core), core) - expect_equal(which(coreness_richcore(to_unweighted(g))$core), core) + expect_equal(which(coreness_rich(g)$core), core) + expect_equal(which(coreness_rich(to_unweighted(g))$core), core) }) test_that("the four sets recover a planted directed structure", { diff --git a/vignettes/articles/topology.Rmd b/vignettes/articles/topology.Rmd index b92957c..aaa9db0 100644 --- a/vignettes/articles/topology.Rmd +++ b/vignettes/articles/topology.Rmd @@ -1062,7 +1062,7 @@ Along the way, you have learned to use these functions: | `node_by_core()` | continuous score, 0 to 1, for how core-like each node is | | `net_by_core()` | correlation of the network with an ideal core-periphery model | | `node_by_kcoreness()` | each node's _k_-coreness (depth in the network's successive cores) | -| `coreness_richcore()`, `coreness_correlation()` | the methods behind those functions; `coreness_richcore()` reads weights and direction | +| `coreness_rich()`, `coreness_correlation()` | the methods behind those functions; `coreness_rich()` reads weights and direction | | `tie_weights()`, `to_unweighted()` | read the tie weights, or drop them | | `net_x_hierarchy()` | Krackhardt's four graph-theoretic dimensions of hierarchy | | `net_by_connectedness()` | proportion of dyads that can reach each other | From 023414a9124d589bbd860ae52be69c4e45b4691c Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 20:40:20 +0200 Subject: [PATCH 51/68] Fixed `net_by_diameter()`, `net_by_length()`, and `net_by_compactness()` erroring on networks holding signs as negative weights --- NEWS.md | 11 +++++---- R/measure_cohesion.R | 22 +++++++++++++++--- R/netrics-utils.R | 19 +++++++++++++++- man/measure_breadth.Rd | 11 +++++++++ man/measure_cohesion.Rd | 10 +++++++++ tests/testthat/test-measure_cohesion.R | 31 ++++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 10 deletions(-) diff --git a/NEWS.md b/NEWS.md index c76d18b..a648ba3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -49,15 +49,12 @@ - Previously unbounded `(n-1)/sum(indegree)`, so now `net_x_hierarchy()` compares efficiency against three quantities already on `[0,1]` - Fixed `net_by_immunity()` returning a negative herd immunity threshold when \eqn{R < 1} - Fixed `net_by_density()`, `net_by_equivalency()` and `node_by_reciprocity()` summing tie weights where they should have counted ties - - Weighted networks could report a proportion above 1 - - All three now dichotomise their input, and say so when given weights - Improved `node_by_closeness()` to validate `direction` via `match.arg()` - Removed `direction` from `net_by_betweenness()` which never used it - Moved `node_by_posneg()` to the eigenvector doc group as a Katz matrix-inversion walk-based measure for signed networks -- Added `method` to `node_by_subgraph()` - - `method` chooses which closed walks to count: `"all"` (the default), `"odd"` or `"even"` - - These sum as `"odd" + "even" == "all"` -- Improved `node_by_subgraph()` to honour tie weights +- Improved `node_by_subgraph()` + - Now honours tie weights + - Added `method=` to choose which closed walks to count: `"odd"`, `"even"`, or`"all"` (default, both) - Documented measure aliases - `node_by_closeness()` as the Sabidussi index - `node_by_degree()` on a weighted network as strength or weighted degree centrality @@ -84,6 +81,8 @@ - At-risk denominator recorded at the end of each period rather than the start, so can exceed 1 - Fixed `net_by_balance()` erroring on networks that hold signs as negative weights, which is how 'stocnet' objects keep them - Added family-wide contract test sweeping every measure for declared ranges, normalisation, and argument effects +- Fixed `net_by_diameter()`, `net_by_length()`, and `net_by_compactness()` erroring on networks holding signs as negative weights, which were read as a distance + - These measures now consider only the positive ties ## Memberships diff --git a/R/measure_cohesion.R b/R/measure_cohesion.R index e714003..59a2c57 100644 --- a/R/measure_cohesion.R +++ b/R/measure_cohesion.R @@ -17,6 +17,13 @@ #' @template param_data #' @family cohesion #' @template net_measure +#' @section Signed networks: +#' `net_by_compactness()` measures 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. +#' The other measures in this topic do not depend on distance, +#' and so use every tie whatever its sign. NULL #' @rdname measure_cohesion @@ -80,7 +87,8 @@ net_by_compactness <- function(.data) { .data <- manynet::expect_nodes(.data) # note that igraph's default mode ignores direction, which would treat a # directed network as though every tie ran both ways - dists <- igraph::distances(manynet::as_igraph(.data), mode = "out") + dists <- igraph::distances(manynet::as_igraph(.to_positive(.data)), + mode = "out") recip <- 1/dists diag(recip) <- 0 # exclude self-pairs recip[!is.finite(recip)] <- 0 # unreachable pairs contribute nothing @@ -139,6 +147,14 @@ net_by_independence <- function(.data){ #' @template param_data #' @family cohesion #' @template net_measure +#' @section Signed networks: +#' Both measures count path lengths, 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. Use [manynet::to_unsigned()] first to control this yourself. +#' +#' Note that dropping the negative ties can disconnect the network, +#' in which case the measure covers the reachable pairs only. NULL #' @rdname measure_breadth @@ -149,7 +165,7 @@ NULL #' @export net_by_diameter <- function(.data){ .data <- manynet::expect_nodes(.data) - object <- manynet::as_igraph(.data) + object <- manynet::as_igraph(.to_positive(.data)) make_network_measure(igraph::diameter(object, directed = manynet::is_directed(object)), object, call = deparse(sys.call()), @@ -165,7 +181,7 @@ net_by_diameter <- function(.data){ #' @export net_by_length <- function(.data){ .data <- manynet::expect_nodes(.data) - object <- manynet::as_igraph(.data) + object <- manynet::as_igraph(.to_positive(.data)) make_network_measure(igraph::mean_distance(object, directed = manynet::is_directed(object)), object, call = deparse(sys.call()), diff --git a/R/netrics-utils.R b/R/netrics-utils.R index a4da646..b1425d0 100644 --- a/R/netrics-utils.R +++ b/R/netrics-utils.R @@ -92,4 +92,21 @@ seq_nodes <- function(.data){ soln } -# nocov end \ No newline at end of file +# nocov end + +# A 'stocnet' object holds a tie's sign as the sign of its weight, so a signed +# network reaches igraph carrying a `weight` attribute of -1 and 1. igraph's +# shortest path functions read any attribute of that name as a distance, and +# either abort on the negative values or report a negative cycle. +# +# Dropping the attribute would keep the negative ties as paths of length one, +# which is the wrong reading: a negative tie is hostility, not a channel along +# which cohesion travels. Path-based measures therefore run over the positive +# ties alone, as `node_x_clique()` does for the same reason. +.to_positive <- function(.data){ + if(manynet::is_signed(.data)){ + manynet::snet_info("Using only the positive ties,", + "since a negative tie does not carry cohesion.") + manynet::to_unsigned(.data, keep = "positive") + } else .data +} diff --git a/man/measure_breadth.Rd b/man/measure_breadth.Rd index da67be5..a73cdf2 100644 --- a/man/measure_breadth.Rd +++ b/man/measure_breadth.Rd @@ -32,6 +32,17 @@ These functions return values or vectors relating to how broad a network is. \item \code{net_by_length()} measures the average path length in the network. } } +\section{Signed networks}{ + +Both measures count path lengths, 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. Use \code{\link[manynet:to_unsigned]{manynet::to_unsigned()}} first to control this yourself. + +Note that dropping the negative ties can disconnect the network, +in which case the measure covers the reachable pairs only. +} + \examples{ net_by_diameter(fict_marvel) net_by_diameter(to_giant(fict_marvel)) diff --git a/man/measure_cohesion.Rd b/man/measure_cohesion.Rd index cd5b47c..3dfad84 100644 --- a/man/measure_cohesion.Rd +++ b/man/measure_cohesion.Rd @@ -44,6 +44,16 @@ in the network. or size of the largest independent set in the network. } } +\section{Signed networks}{ + +\code{net_by_compactness()} measures 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. +The other measures in this topic do not depend on distance, +and so use every tie whatever its sign. +} + \section{Compactness}{ Compactness is the average of the reciprocal distances between all pairs diff --git a/tests/testthat/test-measure_cohesion.R b/tests/testthat/test-measure_cohesion.R index dfdffb6..6f7bf34 100644 --- a/tests/testthat/test-measure_cohesion.R +++ b/tests/testthat/test-measure_cohesion.R @@ -62,3 +62,34 @@ test_that("net_by_compactness respects tie direction", { expect_lt(as.numeric(net_by_compactness(chain)), as.numeric(net_by_compactness(chain + t(chain)))) }) + +test_that("path measures work on a network holding signs as negative weights", { + # `fict_marvel` is signed but not weighted, so its ties reach igraph as a + # `weight` attribute of -1 and 1, which igraph would read as a distance + expect_true(manynet::is_signed(fict_marvel)) + expect_false(manynet::is_weighted(fict_marvel)) + expect_s3_class(net_by_diameter(fict_marvel), "network_measure") + expect_s3_class(net_by_length(fict_marvel), "network_measure") + expect_s3_class(net_by_compactness(fict_marvel), "network_measure") + # each equals the same measure over the positive ties taken explicitly + positive <- manynet::to_unsigned(fict_marvel, keep = "positive") + expect_equal(as.numeric(net_by_diameter(fict_marvel)), + as.numeric(net_by_diameter(positive))) + expect_equal(as.numeric(net_by_length(fict_marvel)), + as.numeric(net_by_length(positive))) + expect_equal(as.numeric(net_by_compactness(fict_marvel)), + as.numeric(net_by_compactness(positive))) + # and the negative ties really were excluded, not merely stripped of their + # weight: a network keeping all 1241 ties but forgetting their signs gives a + # different answer from the 960 positive ties alone + expect_lt(manynet::net_ties(positive), manynet::net_ties(fict_marvel)) + signless <- igraph::delete_edge_attr(manynet::as_igraph(fict_marvel), "weight") + expect_false(isTRUE(all.equal(as.numeric(net_by_length(fict_marvel)), + igraph::mean_distance(signless)))) +}) + +test_that("an unsigned network is untouched by the sign handling", { + expect_values(net_by_diameter(ison_adolescents), 4) + expect_values(net_by_length(ison_adolescents), 2.071) + expect_values(net_by_compactness(ison_adolescents), 0.616) +}) From 792bfa6d1c42e0f72fc47353e46f1078fa905862 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Thu, 27 Aug 2026 20:45:43 +0200 Subject: [PATCH 52/68] Get around manynet version requirement --- DESCRIPTION | 2 +- R/measure_centrality_closeness.R | 2 +- R/measure_centrality_degree.R | 2 +- R/measure_centrality_eigen.R | 2 +- R/netrics-utils.R | 11 +++++++++++ inst/tutorials/netrics3/position.Rmd | 3 +++ vignettes/articles/position.Rmd | 3 +++ 7 files changed, 21 insertions(+), 4 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index f3d1202..e55777a 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -15,7 +15,7 @@ Encoding: UTF-8 LazyData: true Depends: R (>= 4.1.0), - manynet (>= 2.3.0) + manynet (>= 2.2.3) Imports: dplyr, igraph (>= 2.1.0) diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index 1c0845f..e10a294 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -567,7 +567,7 @@ NULL #' @export tie_by_closeness <- function(.data, normalized = TRUE){ .data <- manynet::expect_ties(.data) - edge_adj <- manynet::to_linegraph(.data) + edge_adj <- .to_linegraph(.data) out <- node_by_closeness(edge_adj, normalized = normalized) class(out) <- "numeric" make_tie_measure(out, .data, measure = "closeness centrality", diff --git a/R/measure_centrality_degree.R b/R/measure_centrality_degree.R index 342ee17..55ab449 100644 --- a/R/measure_centrality_degree.R +++ b/R/measure_centrality_degree.R @@ -250,7 +250,7 @@ NULL #' @export tie_by_degree <- function(.data, normalized = TRUE){ .data <- manynet::expect_ties(.data) - edge_adj <- manynet::to_linegraph(.data) + edge_adj <- .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 b84c5ee..17bea20 100644 --- a/R/measure_centrality_eigen.R +++ b/R/measure_centrality_eigen.R @@ -452,7 +452,7 @@ NULL #' @export tie_by_eigenvector <- function(.data, normalized = TRUE){ .data <- manynet::expect_ties(.data) - edge_adj <- manynet::to_linegraph(.data) + edge_adj <- .to_linegraph(.data) out <- node_by_eigenvector(edge_adj, normalized = normalized) class(out) <- "numeric" make_tie_measure(out, .data, measure = "eigenvector centrality", diff --git a/R/netrics-utils.R b/R/netrics-utils.R index b1425d0..19d3c68 100644 --- a/R/netrics-utils.R +++ b/R/netrics-utils.R @@ -40,6 +40,17 @@ 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) +} + # 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. diff --git a/inst/tutorials/netrics3/position.Rmd b/inst/tutorials/netrics3/position.Rmd index 89b7c7e..8d24a20 100644 --- a/inst/tutorials/netrics3/position.Rmd +++ b/inst/tutorials/netrics3/position.Rmd @@ -16,6 +16,9 @@ description: > ```{r pkgs, include = FALSE, eval=TRUE} library(netrics) library(autograph) +# Compatibility shim: manynet renamed `to_blocks()` to `to_blockmodel()` in +# 2.3.0. Remove once manynet 2.3.x is on CRAN. +if (!exists("to_blockmodel")) to_blockmodel <- manynet::to_blocks ``` ```{r setup, include = FALSE, purl=FALSE, eval=TRUE} diff --git a/vignettes/articles/position.Rmd b/vignettes/articles/position.Rmd index 78a67d7..1b06de2 100644 --- a/vignettes/articles/position.Rmd +++ b/vignettes/articles/position.Rmd @@ -13,6 +13,9 @@ description: > ```{r pkgs, include = FALSE, eval=TRUE} library(netrics) library(autograph) +# Compatibility shim: manynet renamed `to_blocks()` to `to_blockmodel()` in +# 2.3.0. Remove once manynet 2.3.x is on CRAN. +if (!exists("to_blockmodel")) to_blockmodel <- manynet::to_blocks ``` ```{r setup, include = FALSE, purl=FALSE, eval=TRUE} From dcb31f54f18ec64f14ab4203c615a4408ceb566c Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 08:37:57 +0200 Subject: [PATCH 53/68] Fixed signless call in test --- tests/testthat/test-measure_cohesion.R | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/testthat/test-measure_cohesion.R b/tests/testthat/test-measure_cohesion.R index 6f7bf34..cb70b3e 100644 --- a/tests/testthat/test-measure_cohesion.R +++ b/tests/testthat/test-measure_cohesion.R @@ -83,7 +83,11 @@ test_that("path measures work on a network holding signs as negative weights", { # weight: a network keeping all 1241 ties but forgetting their signs gives a # different answer from the 960 positive ties alone expect_lt(manynet::net_ties(positive), manynet::net_ties(fict_marvel)) - signless <- igraph::delete_edge_attr(manynet::as_igraph(fict_marvel), "weight") + # manynet 2.2.3 carries the sign in a 'sign' attribute; 2.3.0 carries it as a + # negative 'weight'. Drop whichever this version uses. + signless <- manynet::as_igraph(fict_marvel) + for (a in intersect(c("weight", "sign"), igraph::edge_attr_names(signless))) + signless <- igraph::delete_edge_attr(signless, a) expect_false(isTRUE(all.equal(as.numeric(net_by_length(fict_marvel)), igraph::mean_distance(signless)))) }) From 3b1873b829135982f6f5823a513789b15f202f1d Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 09:08:35 +0200 Subject: [PATCH 54/68] Fixed `node_in_fluid()` and `node_in_spinglass()` --- NEWS.md | 46 +++++++++++++++--------------- R/measure_centrality_closeness.R | 6 +++- R/measure_closure.R | 26 +++++++++++++++-- R/member_community.R | 33 ++++++++++++--------- man/measure_closure.Rd | 4 +++ man/measure_closure_node.Rd | 8 ++++++ tests/testthat/helper-netrics.R | 18 ++++++++++-- tests/testthat/test-measure_net.R | 4 ++- tests/testthat/test-member_nodes.R | 5 ++++ 9 files changed, 107 insertions(+), 43 deletions(-) diff --git a/NEWS.md b/NEWS.md index a648ba3..76e5567 100644 --- a/NEWS.md +++ b/NEWS.md @@ -18,12 +18,22 @@ - `variant`, which variant was computed where a measure offers a choice, e.g. `net_by_reciprocity()` reports "ratio" when asked for the ratio - Printing is a companion change in `{manynet}`, which defaults to previous behavior - Added `measure`, `range`, `normalization`, and `variant` reporting to every measure where applicable -- Updated documentation such that the measures that certain arguments produce are discoverable by name +- Updated documentation such that measures that functions and certain arguments produce are discoverable by name - `node_by_betweenness(cutoff = k)` is distance-bounded or range-limited betweenness - `node_by_reach(cutoff = k)` is geodesic k-path centrality + - `node_by_closeness()` as the Sabidussi index + - `node_by_degree()` on a weighted network as strength or weighted degree centrality + - `node_by_alpha()` as Katz status + - `node_by_hub()` and `node_by_authority()` as the two halves of Kleinberg's HITS + - `node_by_transitivity()` as the local clustering coefficient + - `tie_by_betweenness()` as edge betweenness + - `node_by_subgraph()` as a node's contribution to the Estrada index + - `node_by_induced()` and `node_by_vitality()` are the betweenness and closeness instances of Latora and Marchiori's delta centrality, and cross-referenced them to each other + - `node_by_information()` is the closeness member of the current-flow family, whose betweenness member netrics does not yet offer + - Stopped `node_by_induced()` also calling itself "vitality centrality", which collided with `node_by_vitality()` - Improved specificity of arguments, separating normalising from scaling - Renamed `scale` argument to `scaled`; old spelling still works but warns - - Corrected documentation claiming that all measures return normalized values by default + - Corrected claim that all measures return normalized values by default - Improved consistency by consolidating every per-step discount as `decay` - Always proportional [0,1] where higher values discount less - Was `alpha` in `node_by_alpha()`, `beta` in `regularity_rolesim()` @@ -32,10 +42,6 @@ - Added `decay` to `node_by_pagerank()`, exposing the damping factor previously fixed at 0.85 - Added `decay` to `node_by_subgraph()`, weighting closed walks by length, which Estrada calls `t` - Old spellings still work but warn, as `scale` does -- Added `net_by_cyclicality()` for detecting generalised exchange -- Added `net_by_compactness()` for the average closeness of all pairs of nodes -- Added `node_by_integration()` and `net_by_integration()` for Valente and Foreman's integration and radiality -- Added `node_by_radiality()` as a shortcut for `node_by_integration(direction = "out")` - Fixed `node_by_degree()` to default to `alpha = 0` to match documentation - Fixed `mode_by_betweenness()` to accept only `"all"` and `"in"`, as implemented - Fixed `node_by_reach()` counting the node itself so normalised scores could exceed 1 @@ -55,34 +61,27 @@ - Improved `node_by_subgraph()` - Now honours tie weights - Added `method=` to choose which closed walks to count: `"odd"`, `"even"`, or`"all"` (default, both) -- Documented measure aliases - - `node_by_closeness()` as the Sabidussi index - - `node_by_degree()` on a weighted network as strength or weighted degree centrality - - `node_by_alpha()` as Katz status - - `node_by_hub()` and `node_by_authority()` as the two halves of Kleinberg's HITS - - `node_by_transitivity()` as the local clustering coefficient - - `tie_by_betweenness()` as edge betweenness - - `node_by_subgraph()` as a node's contribution to the Estrada index - - `node_by_induced()` and `node_by_vitality()` are the betweenness and closeness instances of Latora and Marchiori's delta centrality, and cross-referenced them to each other - - `node_by_information()` is the closeness member of the current-flow family, whose betweenness member netrics does not yet offer - - Stopped `node_by_induced()` also calling itself "vitality centrality", which collided with `node_by_vitality()` - Updated references in centrality documentation - Corrected `node_by_eigenvector()` to cite Bonacich (1972) as the origin of the measure, rather than only Bonacich (1991) - Added Freeman (1978) to `node_by_degree()` and the centralisation functions, the source of the centralisation index they apply - Added references to `tie_by_betweenness()`, which had none - Added Sabidussi (1966) to closeness, Boldi and Vigna (2014) to harmonic, Borgatti and Everett (2006) to reach, Brandes (2008) and Ercsey-Ravasz et al. (2012) to betweenness, Watts and Strogatz (1998) and Holland and Leinhardt (1971) to node transitivity, and Page et al. (1999) to pagerank - Added `net_by_bipartivity()` for how close a network is to being bipartite +- Added `net_by_cyclicality()` for detecting generalised exchange +- Added `net_by_compactness()` for the average closeness of all pairs of nodes +- Added `node_by_integration()` and `net_by_integration()` for Valente and Foreman's integration and radiality +- Added `node_by_radiality()` as a shortcut for `node_by_integration(direction = "out")` - Added `net_by_inconsistency()` for how far a partition's blocks depart from ideal types - Ideal types are `nul`, `com`, `reg`, `rdo`, `cdo` and `dnc` - Generalises `net_by_factions()` beyond structural equivalence - Fixed `node_by_equivalency()` erroring on any network, despite being documented for the two-mode case - Fixed `node_by_diversity()` reporting an undefined object in its message about substituting an inapplicable index -- Corrected `net_by_transmissibility()` to no longer declare itself a proportion - - At-risk denominator recorded at the end of each period rather than the start, so can exceed 1 +- Fixed `net_by_transmissibility()` declaring itself a proportion - Fixed `net_by_balance()` erroring on networks that hold signs as negative weights, which is how 'stocnet' objects keep them -- Added family-wide contract test sweeping every measure for declared ranges, normalisation, and argument effects - Fixed `net_by_diameter()`, `net_by_length()`, and `net_by_compactness()` erroring on networks holding signs as negative weights, which were read as a distance - These measures now consider only the positive ties +- Fixed `node_by_reciprocity()` to return 1 throughout for any undirected network +- Fixed `node_by_information()` on rectangular incidence matrices by flattening with `manynet::to_multilevel()` ## Memberships @@ -95,9 +94,13 @@ - `node_in_community()` considers only these algorithms when `k` is given - `k` also accepts `"silhouette"`, `"elbow"`, and `"strict"`, as in `node_in_equivalence()` - Note `k=` is now the second argument, so positional calls such as `node_in_louvain(x, 0.5)` must become `node_in_louvain(x, resolution = 0.5)` +- Fixed `node_in_fluid()` and `node_in_spinglass()` returning nothing on a disconnected network, now abort loudly +- Added `node_in_labels()` for label propagation community detection +- Renamed `times=` in `node_in_walktrap()` to `steps=`, which is more descriptive and consistent with `{igraph}` - Added `consensus=` to `node_in_community()` for combining partitions of all applicable algorithms - Runs each algorithm (stochastic ones `times`), then converges on how often each pair of nodes is grouped together - `consensus = FALSE` default, and ignored where network small enough for `node_in_optimal()` + - Fixed returning nothing but an error whenever verbosity was not `"verbose"` - Renamed `node_by_coreness()` to `node_by_core()` - Fixed search starting points rather than random - Fixed it returning identical scores for a directed network and its reverse @@ -108,9 +111,6 @@ - Adds `direction=` for directed networks, adding `"Sender"` for core out-ties and periphery in-ties and `"Receiver"` for core in-ties and periphery out-ties - Fixed sorting numbered middle labels alphabetically or from arbitrary cluster numbers -- Added `node_in_labels()` for label propagation community detection -- Fixed `node_in_community()` returning nothing but an error whenever verbosity was not `"verbose"` -- Renamed `times=` in `node_in_walktrap()` to `steps=`, which is more descriptive and consistent with `{igraph}` - Added `node_in_block()` for direct blockmodelling, searching partitions for the one that minimises `net_by_inconsistency()` - Fixed `node_in_regular()` to compute regular equivalence using recursive similarity between nodes rather than a triad census - Choose between `regularity = "rolesim"` (default) and `"rege"` diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index e10a294..69cb739 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -338,7 +338,11 @@ node_by_radiality <- function(.data, normalized = TRUE){ node_by_information <- function(.data, normalized = TRUE){ .data <- manynet::expect_nodes(.data) thisRequires("sna") - out <- sna::infocent(manynet::as_network(.data), + # `sna` needs a square sociomatrix, but `as_network()` hands it the + # rectangular incidence matrix of a two-mode network. Flattening to a + # multilevel network first gives every node a row and a column, which is + # how the other path-based measures in this file handle two modes. + out <- sna::infocent(manynet::as_network(manynet::to_multilevel(.data)), gmode = ifelse(manynet::is_directed(.data), "digraph", "graph"), diag = manynet::is_complex(.data), rescale = normalized) diff --git a/R/measure_closure.R b/R/measure_closure.R index 5b5b6b8..b729efd 100644 --- a/R/measure_closure.R +++ b/R/measure_closure.R @@ -71,6 +71,10 @@ net_by_transitivity <- function(.data) { #' the signature of generalised exchange, where resources circulate around a #' loop rather than flowing consistently in one direction. #' +#' A two-mode network contains no cycle of odd length, so it scores 0 here, +#' just as it does for transitivity. Use `net_by_equivalency()` for closure +#' in a two-mode network, which counts four-cycles instead. +#' #' In an undirected network every two-path closed in one direction is also #' closed in the other, so cyclicality and transitivity coincide. #' @references @@ -84,7 +88,13 @@ net_by_transitivity <- function(.data) { #' @export net_by_cyclicality <- function(.data) { .data <- manynet::expect_nodes(.data) - mat <- manynet::as_matrix(manynet::to_unweighted(.data)) + # Flattening to a multilevel network gives every node a row and a column, + # so that a two-mode network can be squared at all. It then scores 0, since + # it contains no cycle of odd length, which is how `net_by_transitivity()` + # already treats two modes. Squaring the raw incidence matrix instead + # errored on uneven modes and returned a meaningless number on even ones. + mat <- manynet::as_matrix( + manynet::to_unweighted(manynet::to_multilevel(.data))) diag(mat) <- 0 twopaths <- mat %*% mat diag(twopaths) <- 0 # i -> j -> i is not a two-path @@ -207,6 +217,11 @@ net_by_congruency <- function(.data, object2){ NULL #' @rdname measure_closure_node +#' @section Node reciprocity: +#' A node's reciprocity is the proportion of its ties that are returned. +#' Where a network is undirected, including where it is two-mode, there is +#' no direction for a tie to be returned along, so every node scores 1. +#' This is what `net_by_reciprocity()` reports for such a network too. #' @examples #' node_by_reciprocity(ison_networkers) #' @export @@ -216,7 +231,14 @@ node_by_reciprocity <- function(.data) { manynet::snet_info("Using the unweighted form of the network.") # A proportion of a node's ties that are returned, so counts of ties rather # than sums of weights: otherwise a reciprocated tie of weight 3 scores 3. - out <- manynet::as_matrix(manynet::to_unweighted(.data)) + # Flattening to a multilevel network squares the matrix, so a two-mode + # network scores 1 throughout: every tie is trivially returned when there is + # no direction to return along. That is what `net_by_reciprocity()` already + # reports for any undirected network. Multiplying the raw incidence matrix + # by its transpose instead errored on uneven modes and returned a + # meaningless number on even ones. + out <- manynet::as_matrix( + manynet::to_unweighted(manynet::to_multilevel(.data))) make_node_measure(rowSums(out * t(out))/rowSums(out), .data, measure = "reciprocity", range = c(0, 1), normalization = "normalized") diff --git a/R/member_community.R b/R/member_community.R index 70bf25e..af71526 100644 --- a/R/member_community.R +++ b/R/member_community.R @@ -523,16 +523,19 @@ node_in_infomap <- function(.data, times = 50){ #' @export node_in_spinglass <- function(.data, max_k = 200, resolution = 1){ .data <- manynet::expect_nodes(.data) - if(!igraph::is_connected(.data)) # note manynet::is_connected will return false - manynet::snet_unavailable("This algorithm only works for connected networks.", - "We suggest using `to_giant()`", - "to select the largest component.") else { - out <- igraph::cluster_spinglass(manynet::as_igraph(.data), - spins = max_k, gamma = resolution, - implementation = ifelse(manynet::is_signed(.data), "neg", "orig") - )$membership - make_node_member(out, .data) - } + # `snet_unavailable()` is silent unless verbosity is raised, so this was a + # branch that returned NULL rather than a membership. Note also that + # `manynet::is_connected()` returns FALSE for a two-mode network, so the + # test is made with igraph. + if(!igraph::is_connected(manynet::as_igraph(.data))) + manynet::snet_abort("This algorithm only works for connected networks.", + "We suggest using {.fn to_giant}", + "to select the largest component.") + out <- igraph::cluster_spinglass(manynet::as_igraph(.data), + spins = max_k, gamma = resolution, + implementation = ifelse(manynet::is_signed(.data), "neg", "orig") + )$membership + make_node_member(out, .data) } #' @rdname member_community_non @@ -556,11 +559,13 @@ node_in_fluid <- function(.data, k = NULL, Kmax = 8L) { .data <- manynet::expect_nodes(.data) k <- check_k(k, .data) .data <- manynet::as_igraph(.data) + # As in `node_in_spinglass()`: this must abort, or the function returns NULL. if (!igraph::is_connected(.data)) { - manynet::snet_unavailable("This algorithm only works for connected networks.", - "We suggest using `to_giant()`", - "to select the largest component.") - } else { + manynet::snet_abort("This algorithm only works for connected networks.", + "We suggest using {.fn to_giant}", + "to select the largest component.") + } + { if(manynet::is_complex(.data)){ manynet::snet_info("This algorithm only works for simple networks.", "Converting to simplex.") diff --git a/man/measure_closure.Rd b/man/measure_closure.Rd index aea0af7..f293d5d 100644 --- a/man/measure_closure.Rd +++ b/man/measure_closure.Rd @@ -73,6 +73,10 @@ hierarchy and of "a friend of a friend is a friend", while cyclicality is the signature of generalised exchange, where resources circulate around a loop rather than flowing consistently in one direction. +A two-mode network contains no cycle of odd length, so it scores 0 here, +just as it does for transitivity. Use \code{net_by_equivalency()} for closure +in a two-mode network, which counts four-cycles instead. + In an undirected network every two-path closed in one direction is also closed in the other, so cyclicality and transitivity coincide. } diff --git a/man/measure_closure_node.Rd b/man/measure_closure_node.Rd index 8b86d60..a4aa46e 100644 --- a/man/measure_closure_node.Rd +++ b/man/measure_closure_node.Rd @@ -48,6 +48,14 @@ For one-mode networks, shallow wrappers of igraph versions exist via For two-mode networks, \code{node_by_equivalency} calculates the proportion of three-paths in the network that are closed by fourth tie to establish a "shared four-cycle" structure. } +\section{Node reciprocity}{ + +A node's reciprocity is the proportion of its ties that are returned. +Where a network is undirected, including where it is two-mode, there is +no direction for a tie to be returned along, so every node scores 1. +This is what \code{net_by_reciprocity()} reports for such a network too. +} + \section{Node transitivity}{ A node's transitivity is the proportion of its neighbours that are diff --git a/tests/testthat/helper-netrics.R b/tests/testthat/helper-netrics.R index 53edeef..f089db5 100644 --- a/tests/testthat/helper-netrics.R +++ b/tests/testthat/helper-netrics.R @@ -66,7 +66,11 @@ bot5 <- function(res, dec = 4){ collect_functions <- function(pattern, package = "netrics"){ getNamespaceExports(package)[grepl(pattern, getNamespaceExports(package))] } -funs_objs <- mget(ls("package:netrics"), inherits = TRUE) +# Renamed functions are kept as warning wrappers in R/netrics-defunct.R for one +# release. They delegate to their replacement, so sweeping them only produces +# deprecation warnings for a name on its way out. +defunct_fns <- c("node_by_coreness") +funs_objs <- mget(setdiff(ls("package:netrics"), defunct_fns), inherits = TRUE) # data_objs <- mget(ls("package:manynet"), inherits = TRUE) # # Filter to relevant objects @@ -79,7 +83,12 @@ funs_objs <- mget(ls("package:netrics"), inherits = TRUE) set.seed(1234) data_objs <- list(directed = generate_random(12, directed = TRUE), - twomode = generate_random(c(6,6)), + # The two modes must differ in size. A square incidence + # matrix passes silently through functions that assume a + # square sociomatrix, which hid real bugs in + # `node_by_core()`, `net_by_cyclicality()` and + # `node_by_information()`. + twomode = generate_random(c(6,8)), labelled = to_signed(add_node_attribute(create_wheel(12), "name", LETTERS[1:12])), attribute = add_node_attribute(create_ring(12), "group", @@ -90,6 +99,11 @@ data_objs <- list(directed = generate_random(12, directed = TRUE), steps = 5, latency = 0.75, recovery = 0.25)) +# `net_by_congruency()` needs two two-mode networks that share a mode: the +# second mode of the first must match the first mode of the second. The +# sweeping fixture cannot be paired with itself once its modes differ in size. +congruent_twomode <- generate_random(c(8,5)) + find_pkg_tutorial_paths <- function(pkg) { tute_folders <- list.dirs(system.file("tutorials", package = pkg), recursive = F) diff --git a/tests/testthat/test-measure_net.R b/tests/testthat/test-measure_net.R index 894a48d..ccc7516 100644 --- a/tests/testthat/test-measure_net.R +++ b/tests/testthat/test-measure_net.R @@ -13,8 +13,10 @@ for(fn in names(net_meas)) { expect_s3_class(net_meas[[fn]](data_objs[[ob]]), "network_measure") else succeed("Only used for signed objects") } else if(grepl("congruency", fn)){ + # paired with a network that shares a mode, since a two-mode network + # whose modes differ in size cannot be congruent with itself if(ob == "twomode") - expect_s3_class(net_meas[[fn]](data_objs[[ob]], data_objs[[ob]]), "network_measure") else + expect_s3_class(net_meas[[fn]](data_objs[[ob]], congruent_twomode), "network_measure") else succeed("Only used for multiple two-mode objects") } else if(grepl("strength|toughness", fn)){ # why is this so slow?? if(ob == "weighted") diff --git a/tests/testthat/test-member_nodes.R b/tests/testthat/test-member_nodes.R index 56b6bc4..faece8a 100644 --- a/tests/testthat/test-member_nodes.R +++ b/tests/testthat/test-member_nodes.R @@ -2,6 +2,11 @@ node_membs <- funs_objs[grepl("node_in_", names(funs_objs))] for(fn in names(node_membs)) { for (ob in names(data_objs)) { test_that(paste(fn, "works on", ob), { + # These two need a connected network, and now abort rather than + # returning nothing when they do not get one. The restriction is about + # connectivity, not about two modes. + skip_if(grepl("fluid|spinglass", fn) && + !igraph::is_connected(manynet::as_igraph(data_objs[[ob]]))) if(grepl("roulette", fn)){ if(ob != "twomode") expect_s3_class(node_membs[[fn]](data_objs[[ob]], num_groups = 3), From 7242154697378ddbafa7c386553b85fa215f36a5 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 09:34:21 +0200 Subject: [PATCH 55/68] Fixed `net_by_independence()` erroring on multilevel networks by measuring whole --- NEWS.md | 1 + R/measure_centrality_degree.R | 7 +++++-- R/measure_cohesion.R | 17 ++++++++++++++++- man/measure_cohesion.Rd | 13 +++++++++++++ tests/testthat/test-measure_cohesion.R | 12 ++++++++++++ 5 files changed, 47 insertions(+), 3 deletions(-) diff --git a/NEWS.md b/NEWS.md index 76e5567..97535f7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -82,6 +82,7 @@ - These measures now consider only the positive ties - Fixed `node_by_reciprocity()` to return 1 throughout for any undirected network - Fixed `node_by_information()` on rectangular incidence matrices by flattening with `manynet::to_multilevel()` +- Fixed `net_by_independence()` erroring on multilevel networks by measuring whole ## Memberships diff --git a/R/measure_centrality_degree.R b/R/measure_centrality_degree.R index 55ab449..7ccf5c5 100644 --- a/R/measure_centrality_degree.R +++ b/R/measure_centrality_degree.R @@ -192,9 +192,12 @@ node_by_multidegree <- function (.data, tie1, tie2){ # whole nodeset. `to_uniplex()` drops nodes that hold none of the retained # ties (e.g. a whole mode of a twomode layer), so the two layers' degrees # would otherwise be of different lengths and get recycled. -uniplex_degree <- function(.data, tie) { +# `node_x_ties()` calls this too, which is why the normalisation and the +# direction are arguments. Their defaults are `node_by_degree()`'s own. +uniplex_degree <- function(.data, tie, normalized = TRUE, direction = "all") { layer <- manynet::to_uniplex(.data, tie) - deg <- as.numeric(node_by_degree(layer)) + deg <- as.numeric(node_by_degree(layer, normalized = normalized, + direction = direction)) if (length(deg) == manynet::net_nodes(.data)) return(deg) out <- stats::setNames(rep(0, manynet::net_nodes(.data)), manynet::node_names(.data)) diff --git a/R/measure_cohesion.R b/R/measure_cohesion.R index 59a2c57..b6fdda6 100644 --- a/R/measure_cohesion.R +++ b/R/measure_cohesion.R @@ -24,6 +24,15 @@ #' Use [manynet::to_unsigned()] first to control this yourself. #' The other measures in this topic do not depend on distance, #' and so use every tie whatever its sign. +#' @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. +#' `net_by_independence()` therefore measures a multilevel network whole, +#' which is the quantity wanted in any case. +#' The projection remains for genuine two-mode networks, +#' where no two nodes of one mode are ever tied +#' and the unprojected answer would be trivially the larger mode. NULL #' @rdname measure_cohesion @@ -121,10 +130,16 @@ net_by_components <- function(.data){ #' @importFrom igraph ivs_size #' @examples #' net_by_independence(ison_adolescents) +#' net_by_independence(fict_actually) #' @export net_by_independence <- function(.data){ .data <- manynet::expect_nodes(.data) - if(manynet::is_twomode(.data)){ + # A multilevel network reports itself as two-mode, but has ties within a + # mode, so it cannot be projected. It needs no projection either: the + # independence number of the whole network is already the quantity wanted. + # The two-mode branch exists because no two nodes of one mode are ever tied + # there, which would make the answer trivially the size of the larger mode. + if(manynet::is_twomode(.data) && !manynet::is_multilevel(.data)){ out <- igraph::ivs_size(manynet::to_mode1(manynet::as_igraph(.data))) } else { out <- igraph::ivs_size(manynet::to_undirected(manynet::as_igraph(.data))) diff --git a/man/measure_cohesion.Rd b/man/measure_cohesion.Rd index 3dfad84..b081a89 100644 --- a/man/measure_cohesion.Rd +++ b/man/measure_cohesion.Rd @@ -54,6 +54,18 @@ The other measures in this topic do not depend on distance, and so use every tie whatever its sign. } +\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{net_by_independence()} therefore measures a multilevel network whole, +which is the quantity wanted in any case. +The projection remains for genuine two-mode networks, +where no two nodes of one mode are ever tied +and the unprojected answer would be trivially the larger mode. +} + \section{Compactness}{ Compactness is the average of the reciprocal distances between all pairs @@ -91,6 +103,7 @@ net_by_compactness(ison_southern_women) net_by_components(fict_thrones) net_by_components(to_undirected(fict_thrones)) net_by_independence(ison_adolescents) +net_by_independence(fict_actually) } \references{ \subsection{On compactness}{ diff --git a/tests/testthat/test-measure_cohesion.R b/tests/testthat/test-measure_cohesion.R index cb70b3e..038a58b 100644 --- a/tests/testthat/test-measure_cohesion.R +++ b/tests/testthat/test-measure_cohesion.R @@ -97,3 +97,15 @@ test_that("an unsigned network is untouched by the sign handling", { expect_values(net_by_length(ison_adolescents), 2.071) expect_values(net_by_compactness(ison_adolescents), 0.616) }) + +test_that("net_by_independence measures a multilevel network whole", { + # a multilevel network reports itself as two-mode but has ties within a + # mode, so the bipartite projection it used to attempt is invalid + expect_true(manynet::is_twomode(fict_actually)) + expect_true(manynet::is_multilevel(fict_actually)) + expect_values(net_by_independence(fict_actually), 76) + # a genuine two-mode network still gets the projection + expect_false(manynet::is_multilevel(ison_southern_women)) + expect_values(net_by_independence(ison_southern_women), 2) + expect_values(net_by_independence(ison_adolescents), 4) +}) From 8e59fdeca583cb94e032803242ce24f72e1bafe7 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 09:41:49 +0200 Subject: [PATCH 56/68] Improved `net_x_triad()` by adding a mixed census --- NEWS.md | 6 ++- R/motif_census.R | 86 ++++++++++++++++++++++--------- R/netrics-defunct.R | 12 +++++ man/defunct.Rd | 10 ++++ man/motif_composition.Rd | 1 + tests/testthat/helper-netrics.R | 2 +- tests/testthat/test-motif_net.R | 32 ++++++++++++ tests/testthat/test-motif_nodes.R | 33 ++++++++++-- 8 files changed, 152 insertions(+), 30 deletions(-) diff --git a/NEWS.md b/NEWS.md index 97535f7..72a16c7 100644 --- a/NEWS.md +++ b/NEWS.md @@ -120,6 +120,10 @@ ## Motifs +- Improved `net_x_triad()` + - Added a mixed census for multiplex networks by folding in `net_x_mixed()` + - Will fire by default for multiplex networks, taking layers by mode rather than by position + - Deprecated `net_x_mixed()` - Added `node_x_clique()`, returning which maximal cliques each node belongs to - It branches on two-mode networks to find bicliques (closes #8, thanks @noortjemay) - Note that it considers only positive ties, since a clique is a cohesive subgroup @@ -129,8 +133,6 @@ - Each branches on whether the attribute given is categorical or continuous - For two-mode networks, `node_x_similarity()` compares each node with those at distance two - These are the nodes it shares a node of the other mode with, following the tertius effect of `{migraph}` and `{goldfish}` (Haunss and Hollway 2023) -- Fixed `node_x_tie()` erroring on diffusion models, where nodes change over waves but ties do not - - `node_in_equivalence()` and `node_in_structural()` also erred on such networks, since they call `node_x_tie()` - Added `net_x_homophily()`, returning the table behind the EI index together with an expected-EI baseline and Yule's Q - Note that on weighted networks this counts ties where `net_by_heterophily()` sums weights, so the two agree only when unweighted diff --git a/R/motif_census.R b/R/motif_census.R index 1b9deaf..5f0ecea 100644 --- a/R/motif_census.R +++ b/R/motif_census.R @@ -314,8 +314,6 @@ node_x_tetrad <- function(.data){ #' - `net_x_dyad()` returns a census of dyad motifs in a network. #' - `net_x_triad()` returns a census of triad motifs in a network. #' - `net_x_tetrad()` returns a census of tetrad motifs in a network. -#' - `net_x_mixed()` returns a census of triad motifs that span -#' a one-mode and a two-mode network. #' #' See also \href{https://www.graphclasses.org/smallgraphs.html}{graph classes}. #' @@ -323,6 +321,8 @@ node_x_tetrad <- function(.data){ #' @family cohesion #' @template net_motif #' @param object2 A second, two-mode network object. +#' Only `net_x_triad()` uses this, and only to take a multilevel census; +#' see its Mixed census section. NULL #' @rdname motif_net @@ -392,15 +392,63 @@ net_x_dyad <- function(.data) { #' #' Note that for undirected and two-mode networks, only 003, 102, and 201 are possible, #' as the other configurations rely on the concept of directionality. +#' @section Mixed census: +#' Where a one-mode and a two-mode network are given together, +#' a multilevel census of the triads that span them is taken instead, +#' after Hollway et al. (2017). +#' Its ten motifs are labelled by how many ties join the pair of nodes at +#' each level, so that `"21"` counts triads whose two nodes are reciprocally +#' tied in the one-mode network and share a partner in the two-mode network. +#' +#' There are two ways to ask for it. +#' Supply the two networks as `.data` and `object2`, +#' the one-mode network first. +#' Or give a single multiplex network holding exactly one one-mode layer and +#' one two-mode layer, such as `fict_marvel`, +#' and the two layers are used in that order. +#' A two-mode network that carries no one-mode layer is not enough, +#' and remains unavailable. +#' +#' Since a census counts configurations, only the presence of a tie counts. +#' Weights and signs are set aside, as `igraph::triad_census()` also does. +#' +#' `node_x_triad()` reports the same ten motifs for each node. #' @references #' ## On the triad census #' Davis, James A., and Samuel Leinhardt. 1967. #' “\href{https://files.eric.ed.gov/fulltext/ED024086.pdf}{The Structure of Positive Interpersonal Relations in Small Groups}.” 55. +#' +#' ## On the mixed census +#' Hollway, James, Alessandro Lomi, Francesca Pallotti, and Christoph Stadtfeld. 2017. +#' “Multilevel Social Spaces: The Network Dynamics of Organizational Fields.” +#' _Network Science_ 5(2): 187–212. +#' \doi{10.1017/nws.2017.8} +#' @source Mixed census adapted from Alejandro Espinosa 'netmem' #' @examples #' net_x_triad(manynet::ison_adolescents) +#' net_x_triad(fict_marvel) #' @export -net_x_triad <- function(.data) { +net_x_triad <- function(.data, object2 = NULL) { .data <- manynet::expect_nodes(.data) + if(!is.null(object2)) + return(make_network_motif(.mixed_census(.data, object2), .data)) + if(manynet::is_multiplex(.data)){ + # a network carrying both a one-mode and a two-mode layer already holds + # everything the multilevel census needs, so use it rather than refuse + layers <- manynet::layer_names(.data) + parts <- lapply(layers, function(l) manynet::to_uniplex(.data, l)) + twomode <- vapply(parts, manynet::is_twomode, FUN.VALUE = logical(1)) + # the layers are told apart by their mode rather than by their order, + # since the census needs the one-mode network first + if(length(layers) == 2 && sum(twomode) == 1){ + manynet::snet_info("Taking a mixed census over the", + "{.val {layers[!twomode]}} and", + "{.val {layers[twomode]}} layers.") + onemode <- parts[[which(!twomode)]] + return(make_network_motif(.mixed_census(onemode, parts[[which(twomode)]]), + onemode)) + } + } if (manynet::is_twomode(.data)) { manynet::snet_abort("A twomode or multilevel option for a triad census is not yet implemented.") } else { @@ -493,31 +541,23 @@ net_x_tetrad <- function(.data){ make_network_motif(out, .data) } -#' @rdname motif_net -#' @source Alejandro Espinosa 'netmem' -#' @references -#' ## On the mixed census -#' Hollway, James, Alessandro Lomi, Francesca Pallotti, and Christoph Stadtfeld. 2017. -#' “Multilevel Social Spaces: The Network Dynamics of Organizational Fields.” -#' _Network Science_ 5(2): 187–212. -#' \doi{10.1017/nws.2017.8} -#' @examples -#' net_x_mixed(fict_marvel) -#' @export -net_x_mixed <- function (.data, object2) { - .data <- manynet::expect_nodes(.data) - if(missing(object2) && manynet::is_multiplex(.data)) { - object2 <- manynet::to_uniplex(.data, unique(manynet::tie_attribute(.data, "type"))[2]) - .data <- manynet::to_uniplex(.data, unique(manynet::tie_attribute(.data, "type"))[1]) - } +# The multilevel triad census of Hollway et al. (2017), over a one-mode +# network and a two-mode network that share their first mode. +# `net_x_triad()` wraps this, either over a supplied pair of networks or over +# the two layers of a multiplex network. +.mixed_census <- function (.data, object2) { if(manynet::is_twomode(.data)) manynet::snet_abort("First object should be a one-mode network") if(!manynet::is_twomode(object2)) manynet::snet_abort("Second object should be a two-mode network") if(manynet::net_dims(.data)[1] != manynet::net_dims(object2)[1]) manynet::snet_abort("Non-conformable arrays") - m1 <- manynet::as_matrix(.data) - m2 <- manynet::as_matrix(object2) + # A census counts configurations, so only the presence of a tie matters. + # The matrices are made binary because the arithmetic below takes the + # complement of each, which a weight or a negative sign would corrupt: + # `igraph::triad_census()` ignores weights for the same reason. + m1 <- (manynet::as_matrix(.data) != 0) * 1 + m2 <- (manynet::as_matrix(object2) != 0) * 1 cp <- function(m) (-m + 1) onemode.reciprocal <- m1 * t(m1) onemode.forward <- m1 * cp(t(m1)) @@ -544,7 +584,7 @@ net_x_mixed <- function (.data, object2) { "02" = sum(onemode.reciprocal * bipartite.null) / 2, "01" = sum(onemode.forward * bipartite.null) / 2 + sum(onemode.backward * bipartite.null) / 2, "00" = sum(onemode.null * bipartite.null) / 2) - make_network_motif(res, .data) + res } # Exposure #### diff --git a/R/netrics-defunct.R b/R/netrics-defunct.R index 5ac5261..9013921 100644 --- a/R/netrics-defunct.R +++ b/R/netrics-defunct.R @@ -28,4 +28,16 @@ node_by_coreness <- function(.data, coreness = NULL, node_by_core(.data, coreness = coreness, direction = direction) } +#' @describeIn defunct Deprecated on 2026-08-28. +#' Folded into `net_x_triad()`, which now takes the multilevel census +#' whenever it is given both a one-mode and a two-mode network, rather than +#' reporting that no such option exists. +#' @template param_data +#' @param object2 A second, two-mode network object. +#' @export +net_x_mixed <- function(.data, object2) { + .Deprecated("net_x_triad", package = "netrics", old = "net_x_mixed") + if(missing(object2)) net_x_triad(.data) else net_x_triad(.data, object2) +} + # nocov end \ No newline at end of file diff --git a/man/defunct.Rd b/man/defunct.Rd index d30b6ad..b483685 100644 --- a/man/defunct.Rd +++ b/man/defunct.Rd @@ -3,9 +3,12 @@ \name{defunct} \alias{defunct} \alias{node_by_coreness} +\alias{net_x_mixed} \title{Functions that have been renamed, superseded, or are no longer working} \usage{ node_by_coreness(.data, coreness = NULL, direction = c("all", "out", "in")) + +net_x_mixed(.data, object2) } \arguments{ \item{.data}{A network object of class \code{stocnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. @@ -23,6 +26,8 @@ directly, and "correlation" otherwise.} For a directed network, "out" scores nodes on the ties they send and "in" on the ties they receive. Ignored for undirected and two-mode networks.} + +\item{object2}{A second, two-mode network object.} } \value{ Results as expected @@ -44,5 +49,10 @@ Renamed \code{node_by_core()}, for symmetry with \code{node_is_core()} and \code{node_in_core()}, and so that "coreness" names only the peeling depth that \code{node_by_kcoreness()} returns. +\item \code{net_x_mixed()}: Deprecated on 2026-08-28. +Folded into \code{net_x_triad()}, which now takes the multilevel census +whenever it is given both a one-mode and a two-mode network, rather than +reporting that no such option exists. + }} \keyword{internal} diff --git a/man/motif_composition.Rd b/man/motif_composition.Rd index 585bcb4..e0c4245 100644 --- a/man/motif_composition.Rd +++ b/man/motif_composition.Rd @@ -150,6 +150,7 @@ the other mode's nodes take \code{NA}. \examples{ node_x_ties(ison_networkers) node_x_ties(ison_algebra) +node_x_ties(fict_marvel) node_x_alters(ison_networkers, "Discipline") node_x_alters(ison_networkers, "Citations") node_x_similarity(ison_networkers, "Discipline") diff --git a/tests/testthat/helper-netrics.R b/tests/testthat/helper-netrics.R index f089db5..1605f0f 100644 --- a/tests/testthat/helper-netrics.R +++ b/tests/testthat/helper-netrics.R @@ -69,7 +69,7 @@ collect_functions <- function(pattern, package = "netrics"){ # Renamed functions are kept as warning wrappers in R/netrics-defunct.R for one # release. They delegate to their replacement, so sweeping them only produces # deprecation warnings for a name on its way out. -defunct_fns <- c("node_by_coreness") +defunct_fns <- c("node_by_coreness", "net_x_mixed") funs_objs <- mget(setdiff(ls("package:netrics"), defunct_fns), inherits = TRUE) # data_objs <- mget(ls("package:manynet"), inherits = TRUE) diff --git a/tests/testthat/test-motif_net.R b/tests/testthat/test-motif_net.R index 6408e4b..a57ee1c 100644 --- a/tests/testthat/test-motif_net.R +++ b/tests/testthat/test-motif_net.R @@ -21,3 +21,35 @@ for(fn in names(net_motifs)) { } } + +test_that("net_x_triad takes a mixed census over a multilevel network", { + # `net_x_triad()` used to refuse this as "not yet implemented", although + # `net_x_mixed()` implemented it in the same file + res <- net_x_triad(fict_marvel) + expect_s3_class(res, "network_motif") + expect_length(res, 10) + expect_equal(names(res), + c("22", "21", "20", "12", "11D", "11U", "10", "02", "01", "00")) + # a census counts configurations, so no count can be negative: the signs + # fict_marvel holds as negative weights must not reach the arithmetic + expect_true(all(as.numeric(res) >= 0)) + # the same census, asked for by supplying the two networks directly + one <- manynet::to_uniplex(fict_marvel, "relationship") + two <- manynet::to_uniplex(fict_marvel, "affiliation") + expect_equal(as.numeric(net_x_triad(one, two)), as.numeric(res)) +}) + +test_that("net_x_triad leaves the ordinary census alone", { + res <- net_x_triad(ison_adolescents) + expect_s3_class(res, "network_motif") + expect_equal(names(res), c("003", "012", "102", "201", "210", "300")) + # a multiplex network of one-mode layers only still gets the flat census + expect_length(net_x_triad(ison_algebra), 16) + # and a two-mode network with no one-mode layer remains unavailable + expect_error(net_x_triad(ison_southern_women), "not yet implemented") +}) + +test_that("net_x_mixed is deprecated in favour of net_x_triad", { + expect_warning(res <- net_x_mixed(fict_marvel), "deprecated") + expect_equal(as.numeric(res), as.numeric(net_x_triad(fict_marvel))) +}) diff --git a/tests/testthat/test-motif_nodes.R b/tests/testthat/test-motif_nodes.R index 82d85c8..96cdb3e 100644 --- a/tests/testthat/test-motif_nodes.R +++ b/tests/testthat/test-motif_nodes.R @@ -71,15 +71,15 @@ test_that("node_x_tetrad census works", { test_that("net_mixed census works", { marvel_friends <- to_unsigned(to_uniplex(fict_marvel, "relationship"), "positive") - test <- net_x_mixed(marvel_friends, to_uniplex(fict_marvel, "affiliation")) + test <- net_x_triad(marvel_friends, to_uniplex(fict_marvel, "affiliation")) expect_equal(unname(test[1]), 1137) expect_equal(names(test[1]), "22") # Errors - expect_error(net_x_mixed(ison_southern_women, + expect_error(net_x_triad(ison_southern_women, to_uniplex(fict_marvel, "affiliation"))) - expect_error(net_x_mixed(to_uniplex(fict_marvel, "affiliation"), + expect_error(net_x_triad(to_uniplex(fict_marvel, "affiliation"), ison_southern_women)) - expect_error(net_x_mixed(ison_karateka, + expect_error(net_x_triad(ison_karateka, to_uniplex(fict_marvel, "affiliation"))) }) @@ -100,3 +100,28 @@ test_that("net_x_brokerage works", { test <- net_x_brokerage(ison_networkers, "Discipline") expect_equal(top3(names(test)), c("Coordinator","Itinerant","Gatekeeper")) }) + +test_that("node_x_tie finds layers whatever the tie attribute is called", { + # ison_monks multiplexes on "layer", so reading "type" gave it no layers + res <- node_x_tie(ison_monks) + expect_s3_class(res, "node_motif") + expect_equal(nrow(res), manynet::net_nodes(ison_monks)) + expect_s3_class(node_x_tie(ison_algebra), "node_motif") +}) + +test_that("node_x_tie reports layers that cannot be stacked", { + # fict_marvel's layers hold different node sets, so no one census spans them + withr::local_options(snet_verbosity = "verbose") + expect_error(node_x_tie(fict_marvel), "node set") +}) + +test_that("node_x_triad reaches the mixed census through net_x_triad", { + skip_on_cran() + # node_x_triad() is a leave-one-out difference of net_x_triad(), so it gains + # the multilevel census without any code of its own + res <- node_x_triad(fict_marvel) + expect_s3_class(res, "node_motif") + expect_equal(dim(res), c(manynet::net_nodes(fict_marvel), 10L)) + expect_equal(colnames(res), + c("22", "21", "20", "12", "11D", "11U", "10", "02", "01", "00")) +}) From d8e01a902169e6cc24b161cada25f8b523419325 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 09:42:06 +0200 Subject: [PATCH 57/68] Improved `node_x_tie()` erroring on multiplex networks --- NEWS.md | 3 ++ R/motif_census.R | 21 ++++++++-- R/motif_composition.R | 11 +++-- man/motif_net.Rd | 55 +++++++++++++++++-------- tests/testthat/test-motif_composition.R | 24 +++++++++++ 5 files changed, 91 insertions(+), 23 deletions(-) diff --git a/NEWS.md b/NEWS.md index 72a16c7..8214fbf 100644 --- a/NEWS.md +++ b/NEWS.md @@ -127,6 +127,9 @@ - Added `node_x_clique()`, returning which maximal cliques each node belongs to - It branches on two-mode networks to find bicliques (closes #8, thanks @noortjemay) - Note that it considers only positive ties, since a clique is a cohesive subgroup +- Improved `node_x_tie()` + - Fixed erroring on diffusion models, which downstream affected `node_in_equivalence()` and `node_in_structural()` + - Fixed erroring on any multiplex network not multiplexed on a `type` tie attribute - Added `node_x_ties()`, describing the distribution of each node's tie values - In a multiplex network it describes their spread across layers - Added `node_x_alters()` and `node_x_similarity()`, describing the composition of each node's alters and their similarity to it diff --git a/R/motif_census.R b/R/motif_census.R index 5f0ecea..6d79214 100644 --- a/R/motif_census.R +++ b/R/motif_census.R @@ -27,9 +27,24 @@ node_x_tie <- function(.data){ object <- manynet::as_igraph(.data) # Only tie-level waves split the census; a diffusion model's ties do not change waved <- "wave" %in% manynet::net_tie_attributes(object) + if (manynet::is_multiplex(.data)) { + # The layers are stacked into one census, which needs them to share a + # node set. `to_uniplex()` drops the nodes a layer does not tie, so a + # network mixing a one-mode and a two-mode layer has nothing to stack. + sizes <- vapply(manynet::layer_names(object), + function(l) manynet::net_nodes(manynet::to_uniplex(object, l)), + FUN.VALUE = numeric(1)) + if (length(unique(sizes)) > 1) + manynet::snet_unavailable( + "A tie census over layers that do not share a node set", + "is not yet available.", + "Here the {.val {names(sizes)}} layers hold {sizes} nodes.", + "Please use {.fn to_uniplex} to census a single layer,", + "or {.fn net_x_triad}, which does span a one-mode and a two-mode layer.") + } if (manynet::is_directed(object)) { if (manynet::is_multiplex(.data)) { - mat <- do.call(rbind, lapply(unique(manynet::tie_attribute(object, "type")), + mat <- do.call(rbind, lapply(manynet::layer_names(object), function(x){ rc <- manynet::as_matrix(manynet::to_uniplex(object, x)) rbind(rc, t(rc)) @@ -47,7 +62,7 @@ node_x_tie <- function(.data){ } } else { if (manynet::is_multiplex(.data)) { - mat <- do.call(rbind, lapply(unique(manynet::tie_attribute(object, "type")), + mat <- do.call(rbind, lapply(manynet::layer_names(object), function(x){ manynet::as_matrix(manynet::to_uniplex(object, x)) })) @@ -66,7 +81,7 @@ node_x_tie <- function(.data){ if(manynet::is_multiplex(.data)){ rownames(mat) <- apply(expand.grid(c(paste0("from", manynet::node_names(object)), paste0("to", manynet::node_names(object))), - unique(manynet::tie_attribute(object, "type"))), + manynet::layer_names(object)), 1, paste, collapse = "_") } else if (waved){ rownames(mat) <- apply(expand.grid(c(paste0("from", manynet::node_names(object)), diff --git a/R/motif_composition.R b/R/motif_composition.R index 9114d03..b5f14de 100644 --- a/R/motif_composition.R +++ b/R/motif_composition.R @@ -59,15 +59,20 @@ NULL #' @examples #' node_x_ties(ison_networkers) #' node_x_ties(ison_algebra) +#' node_x_ties(fict_marvel) #' @export node_x_ties <- function(.data, direction = c("all", "out", "in")){ .data <- manynet::expect_nodes(.data) direction <- match.arg(direction) if(manynet::is_multiplex(.data)){ - layers <- unique(manynet::tie_attribute(.data, "type")) + # `layer_names()` rather than the "type" tie attribute, since a network + # multiplexed on any other attribute would otherwise return no layers at + # all and only the Diversity column + layers <- manynet::layer_names(.data) + # `uniplex_degree()` keeps each layer at the length of the whole nodeset, + # which `to_uniplex()` does not out <- vapply(layers, function(l) - as.numeric(node_by_degree(manynet::to_uniplex(.data, l), - normalized = FALSE, direction = direction)), + uniplex_degree(.data, l, normalized = FALSE, direction = direction), FUN.VALUE = numeric(manynet::net_nodes(.data))) out <- cbind(out, Diversity = .iqv(out)) } else if(manynet::is_weighted(.data)){ diff --git a/man/motif_net.Rd b/man/motif_net.Rd index 810597c..12897b2 100644 --- a/man/motif_net.Rd +++ b/man/motif_net.Rd @@ -5,26 +5,25 @@ \alias{net_x_dyad} \alias{net_x_triad} \alias{net_x_tetrad} -\alias{net_x_mixed} \title{Motifs of network cohesion} \source{ -Alejandro Espinosa 'netmem' +Mixed census adapted from Alejandro Espinosa 'netmem' } \usage{ net_x_dyad(.data) -net_x_triad(.data) +net_x_triad(.data, object2 = NULL) net_x_tetrad(.data) - -net_x_mixed(.data, object2) } \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{object2}{A second, two-mode network object.} +\item{object2}{A second, two-mode network object. +Only \code{net_x_triad()} uses this, and only to take a multilevel census; +see its Mixed census section.} } \value{ A \code{network_motif} named numeric vector or sometimes a data frame with @@ -39,8 +38,6 @@ in a network: \item \code{net_x_dyad()} returns a census of dyad motifs in a network. \item \code{net_x_triad()} returns a census of triad motifs in a network. \item \code{net_x_tetrad()} returns a census of tetrad motifs in a network. -\item \code{net_x_mixed()} returns a census of triad motifs that span -a one-mode and a two-mode network. } See also \href{https://www.graphclasses.org/smallgraphs.html}{graph classes}. @@ -98,6 +95,30 @@ Note that for undirected and two-mode networks, only 003, 102, and 201 are possi as the other configurations rely on the concept of directionality. } +\section{Mixed census}{ + +Where a one-mode and a two-mode network are given together, +a multilevel census of the triads that span them is taken instead, +after Hollway et al. (2017). +Its ten motifs are labelled by how many ties join the pair of nodes at +each level, so that \code{"21"} counts triads whose two nodes are reciprocally +tied in the one-mode network and share a partner in the two-mode network. + +There are two ways to ask for it. +Supply the two networks as \code{.data} and \code{object2}, +the one-mode network first. +Or give a single multiplex network holding exactly one one-mode layer and +one two-mode layer, such as \code{fict_marvel}, +and the two layers are used in that order. +A two-mode network that carries no one-mode layer is not enough, +and remains unavailable. + +Since a census counts configurations, only the presence of a tie counts. +Weights and signs are set aside, as \code{igraph::triad_census()} also does. + +\code{node_x_triad()} reports the same ten motifs for each node. +} + \section{Tetrad census}{ The tetrad census counts the number of four-node configurations in the network. @@ -128,8 +149,8 @@ Graphs of these motifs can be shown using \examples{ net_x_dyad(manynet::ison_algebra) net_x_triad(manynet::ison_adolescents) +net_x_triad(fict_marvel) net_x_tetrad(ison_southern_women) -net_x_mixed(fict_marvel) } \references{ \subsection{On the dyad census}{ @@ -150,6 +171,14 @@ Davis, James A., and Samuel Leinhardt. 1967. “\href{https://files.eric.ed.gov/fulltext/ED024086.pdf}{The Structure of Positive Interpersonal Relations in Small Groups}.” 55. } +\subsection{On the mixed census}{ + +Hollway, James, Alessandro Lomi, Francesca Pallotti, and Christoph Stadtfeld. 2017. +“Multilevel Social Spaces: The Network Dynamics of Organizational Fields.” +\emph{Network Science} 5(2): 187–212. +\doi{10.1017/nws.2017.8} +} + \subsection{On the tetrad census}{ Ortmann, Mark, and Ulrik Brandes. 2017. @@ -162,14 +191,6 @@ McMillan, Cassie, and Diane Felmlee. 2020. \emph{Social Psychology Quarterly} 83(4): 383-404. \doi{10.1177/0190272520944151} } - -\subsection{On the mixed census}{ - -Hollway, James, Alessandro Lomi, Francesca Pallotti, and Christoph Stadtfeld. 2017. -“Multilevel Social Spaces: The Network Dynamics of Organizational Fields.” -\emph{Network Science} 5(2): 187–212. -\doi{10.1017/nws.2017.8} -} } \seealso{ Other cohesion: diff --git a/tests/testthat/test-motif_composition.R b/tests/testthat/test-motif_composition.R index 748a1b4..2322a8b 100644 --- a/tests/testthat/test-motif_composition.R +++ b/tests/testthat/test-motif_composition.R @@ -119,3 +119,27 @@ test_that("attribute resolution accepts names and vectors alike", { expect_error(node_x_alters(ison_networkers, "nonexistent")) expect_error(node_x_alters(ison_networkers, c(1, 2, 3))) }) + +test_that("node_x_ties finds layers whatever the tie attribute is called", { + # `ison_algebra` multiplexes on a "type" attribute, the others on "layer". + # Reading "type" alone returned no layers at all and only a Diversity + # column of NAs, without erroring. + alg <- node_x_ties(ison_algebra) + expect_s3_class(alg, "node_motif") + expect_equal(colnames(alg), c("social", "tasks", "friends", "Diversity")) + monks <- node_x_ties(ison_monks) + expect_equal(colnames(monks), + c("like", "esteem", "influence", "praise", "Diversity")) + expect_false(all(is.na(monks[, "Diversity"]))) + expect_true(all(monks[, "like"] >= 0)) +}) + +test_that("node_x_ties keeps every node where a layer drops some", { + # `to_uniplex()` reduces fict_marvel's relationship layer to 53 of its 194 + # nodes, so the layers must be padded back to the whole nodeset + res <- node_x_ties(fict_marvel) + expect_s3_class(res, "node_motif") + expect_equal(nrow(res), manynet::net_nodes(fict_marvel)) + expect_equal(colnames(res), c("relationship", "affiliation", "Diversity")) + expect_false(all(is.na(res[, "Diversity"]))) +}) From e189d15b36af346731a8363474b099a2f39537c3 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 10:36:33 +0200 Subject: [PATCH 58/68] Updated CONTRIBUTING from current practice --- .github/CONTRIBUTING.md | 262 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 247 insertions(+), 15 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f696daf..3a0c5df 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -26,6 +26,15 @@ The GitHub page allows to access the issues assigned to you and check the commit You can also access the documents in the repository, although this won't be necessary after you have cloned it on your computer via Fork. +### Identifying issues + +Please use the issues tracker on GitHub to identify any function-related issues. +You can use these issues to track progress on the issue and +to comment or continue a conversation on that issue. +The most useful issues are ones that precisely identify an error, +or propose a test that should pass but instead fails. +Examples for documentation are also most welcome. + ### Cloning Once you have downloaded Fork, the first thing you have to do is to @@ -55,6 +64,11 @@ if you don't want to do it in two steps. If you are working on a separate branch, it is important to select this branch when pushing to origin/main. +Commits may reference an existing GitHub issue number. +Where the issue number is preceded by `resolve`/`resolves`/`resolved`, +`close`/`closes`/`closed`, or `fix`/`fixes`/`fixed` (capitalised or not), +GitHub updates the status of the issue automatically. + ### Branching and CI - `main` is the release branch; `develop` is the working branch (clone/work on `develop`). @@ -66,6 +80,9 @@ it is important to select this branch when pushing to origin/main. In terms of style, we are aiming for pleasant predictability in terms of user experience. To that end, we have a regular syntax that users can rely on producing expected effects. +Functions in the same family (`node_by_*()`, `node_in_*()`, `node_x_*()`, etc.) +should share argument order and naming, +so that behaviour is guessable across the family. ## Package architecture @@ -92,6 +109,7 @@ Run these from an R console with the working directory set to the package root ( - Spell check: `spelling::spell_check_package()` - Code coverage: `covr::package_coverage()` - Rebuild `README.md` from `README.Rmd`: `devtools::build_readme()` +- Check every topic is in the pkgdown index: `pkgdown::check_pkgdown()` - Build pkgdown site locally: `pkgdown::build_site()` There is no non-R build system — no package.json/Makefile. @@ -115,6 +133,10 @@ Functions are grouped into four families by naming pattern, each with dedicated When adding a new analytic function, pick the family that matches its semantics and follow the existing naming scheme exactly. This predictability is a stated project goal. +Where one concept appears in several families, give it one stem in each: +`node_is_core()`, `node_by_core()` and `node_in_core()` are the mark, the measure and +the membership of the same idea. + ### Method helper naming Besides the four analytic families, some functions take a **character argument that selects a method**. @@ -128,6 +150,7 @@ Users can therefore find the implementation, and its documentation, from the arg | `method_kselect` | an integer, the number of clusters | `k_*` | `k =` | | `method_cluster` | an `hclust` clustering object | `cluster_*` | `cluster =` | | `method_regularity` | a node-by-node similarity matrix | `regularity_*` | `regularity =` | +| `method_coreness` | a continuous coreness score plus a core/periphery split | `coreness_*` | `coreness =` | Apply that test when naming a new family. For example, `equivalence_*` would be the wrong name for `regularity_*`, even though those methods are only ever called from `node_in_regular()`: they return a *similarity*, which `cluster_*()` only later partitions into an equivalence. Naming the step for the pipeline's eventual output rather than its own return value breaks the rule. @@ -153,6 +176,38 @@ Two rules about number: Finally, avoid words that imply another stocnet package's remit. `{netrics}` is descriptive; statistical modelling and testing belong to `{migraph}`. This is why the direct blockmodelling search is `node_in_block()` rather than `node_in_blockmodel()`, even though "blockmodel" is the literature's term — prose and `@section` headings should still say blockmodelling, since it is only the exported name that signals remit. +### Argument names and vocabulary + +One word means one thing across the package, and one thing takes one word. +Before adding an argument, look for the name the package already uses for that idea: + +| Argument | Means | +|---|---| +| `normalized` | divide by a theoretical maximum, so scores compare across networks | +| `scaled` | divide by the observed maximum, so the highest-scoring node takes 1 | +| `decay` | any per-step discount, always a proportion on `[0,1]` where higher values discount less | +| `alpha` | only Opsahl et al.'s trade-off between degree and strength in `node_by_degree()` | +| `direction` | `"all"`, `"in"` or `"out"`, validated with `match.arg()` | +| `cutoff` | a geodesic distance bound | +| `k` | a target number of groups, or the name of a `k_*` selection method | +| `cluster`, `coreness`, `regularity` | select a method helper, as above | + +Four points follow from this: + +- Prefer an existing argument to a new one. + `decay` replaced four names for one parameter: `decay` in `node_by_harmonic()`, + `alpha` in `node_by_alpha()`, `beta` in `regularity_rolesim()`, + and PageRank's damping factor, which was not exposed at all. +- Validate a shared argument in one helper, e.g. `check_decay()` + ([R/class_metrics.R](../R/class_metrics.R)), + so that the bound and the message cannot drift apart between measures. +- Document it once as a `man-roxygen/param_*.R` template + (`param_decay`, `param_cutoff`, `param_norm`), and `@template` it everywhere. +- Where a word cannot carry the same meaning everywhere, that is a sign it is the wrong word, + not a licence to overload it. + `node_by_power(exponent=)` is deliberately not a `decay`, + because a negative exponent inverts the measure rather than discounting it. + ### Function body convention Functions consistently: @@ -163,6 +218,85 @@ Functions consistently: All `manynet`/`igraph` calls use explicit `::` namespacing rather than importing whole namespaces (`{manynet}` and `{igraph}` are still listed in `@importFrom` roxygen tags per-file for NAMESPACE generation). +Where the function is a measure, the `make_*_measure()` call also declares +`measure`, `range`, `normalization` and `variant` +(see [Adding a measure](#adding-a-measure)). + +### Input shapes + +Most defects reported against this package are not wrong arithmetic. +They are a network shape the function did not expect. +Before finishing a function, run it on a signed, a weighted, a directed, a two-mode, +a multiplex, a multilevel and a longitudinal network, and decide each case deliberately: + +- **Signed.** A `stocnet` object holds a tie's sign as the sign of its weight, + so a signed network reaches `{igraph}` carrying a `weight` attribute of -1 and 1. + Shortest-path functions read any attribute of that name as a distance, + and either abort on the negative values or report a negative cycle. + Where the measure concerns cohesion or distance, call `.to_positive()` + ([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. +- **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()` + ([R/measure_centrality_degree.R](../R/measure_centrality_degree.R)) + restores the whole nodeset, and is the pattern to follow. +- **Multilevel.** A multilevel network reports itself as two-mode, + but holds ties within a mode as well as between them, so it cannot be projected. + Measure it whole rather than projecting, as `net_by_independence()` now does. +- **Longitudinal and diffusion.** Nodes may change over waves while the ties do not, + so do not assume a nodeset and a tieset of matching length or wave. + +Document each decision in the roxygen block with an `@section` named for the shape, +e.g. `@section Signed networks:` or `@section Multilevel networks:`. +Say what the function does with such a network, +and how the user can control it themselves, e.g. with `manynet::to_unsigned()`. + +### Adding a measure + +1. Name it for its family and level, and put it in the `R/measure_*.R` file of its topic, + sharing a roxygen block with the functions it belongs with. +2. Coerce, branch on the input shapes above, compute, + then wrap the result with the matching `make_*_measure()`. +3. Declare `measure`, `range`, `normalization` and `variant` in that call. + These attributes are what lets a result be read without the manual, + so a measure that resolves its method at run time declares the method it actually used, + as `net_by_diversity()` and `net_by_core()` do. +4. Add it to the right roster in + [tests/testthat/helper-contract.R](../tests/testthat/helper-contract.R), + giving any arguments it needs to be applicable. + `test-measure_registry_contract.R` compares the rosters against the namespace, + so a measure in no roster fails the build rather than escaping the sweep. + Where a measure cannot take the roster's shape, add it to `uncontracted_measures` + with a comment saying why, and cover it in its family's contract file instead. +5. Add assertions to the mirroring `test-measure_*.R`. +6. Check the website still builds, and add one `NEWS.md` bullet. + +An exemption is a declaration, not a gap: where an argument cannot have an effect, +record it in the exemption list with the reason, as the eigenvector measures do. + +### Renaming or retiring a name + +A rename is cheap for us and expensive for users, so each one carries a shim: + +- **A renamed function** gets a forwarding shim in + [R/netrics-defunct.R](../R/netrics-defunct.R), which calls `.Deprecated()` + and is documented with `@describeIn defunct Deprecated on .` + plus one sentence on why the new name is better. + These shims are cleared at each minor release. +- **A renamed argument** keeps the old spelling as an argument defaulting to `NULL`, + resolved by a `resolve_*()` helper in [R/class_metrics.R](../R/class_metrics.R) + that warns and returns the new value. + Use base `warning()` there rather than `manynet::snet_warn()`: + `snet_*()` output is quiet by default, and a renamed argument is something + the user must act on. +- **Fold rather than duplicate where the new name subsumes the old.** + `net_x_mixed()` became a case of `net_x_triad()`, which now takes the multilevel census + whenever it is given both a one-mode and a two-mode network. +- **Update the prose too.** The README, the tutorials and the vignettes advertise + function names, so grep for the old name after every rename. + ### File organization `R/` files are organized by function family and topic, not one-file-per-function: e.g. `measure_centrality_degree.R`, `measure_cohesion.R`, `member_community.R`, `motif_brokerage.R`, `mark_nodes.R`/`mark_ties.R`. Related functions (e.g. `node_by_degree()` and its shortcuts `node_by_deg()`, `node_by_indegree()`, `node_by_outdegree()`) share one `@name`/roxygen block and file. @@ -171,14 +305,61 @@ Shared roxygen documentation blocks live in `man-roxygen/` as `@template` fragme ### Tests -Tests in `tests/testthat/` mirror the `R/` files (e.g. `test-measure_centrality.R`, `test-member_community.R`). +This package uses the `testthat` package for testing functions. +Please see the [testthat website](https://testthat.r-lib.org) for more details. +`testthat` edition 3 with parallel execution is configured in `DESCRIPTION` (`Config/testthat/parallel: true`). +`Config/testthat/start-first` should prioritise the test files that take longest to run. + +The main testing is *functional* (family-enumerating) testing. +Rather than a test per function, each family sweeps its whole roster from +[tests/testthat/helper-contract.R](../tests/testthat/helper-contract.R) +and checks the promises the documentation makes: +that a measure returns the right shape, declares what it computed, +stays inside the range it declares, performs the normalisation it declares, +and that its arguments actually do something. +Where a function does not yet meet the contract, +the sweep records an audit message rather than failing, +so the outstanding gaps are enumerated on every run instead of being +either invisible or a red build. +`report_contract_gaps()` prints that list, which is the remaining work, +and the aim is for it to shrink to empty. + +`test-tutorials_netrics.R` evaluates the code chunks of the tutorials in `inst/tutorials/`, +so tutorial code that errors or raises a deprecation warning fails the suite. + +Any additional testing that is required for particular functions is covered in +test files that mirror the `R/` files (e.g. `test-measure_centrality.R`, `test-member_community.R`). `tests/testthat/helper-netrics.R` defines shared custom expectations/helpers used across tests: - `expect_values(object, ref)` — compares rounded numeric output against reference values. - `expect_mark(object, ref, top)` — compares character/label output. - `top3()`/`bot3()`/`top5()`/`bot5()` — pull top/bottom N values (rounded) from a result for use as terse reference vectors in assertions. - -`testthat` edition 3 with parallel execution is configured in `DESCRIPTION` (`Config/testthat/parallel: true`). -`Config/testthat/start-first` prioritizes `tutorials_netrics, measure_net, member_nodes, measure_nodes`. +The aim is to work towards comprehensive coverage, +so each change should be fully covered by tests. +However, we also need to keep an eye on the clock: +CRAN complains if tests take too long, +so use small fixtures or skip taxing tests. +`# nocov start` and `# nocov end` can be used to exclude lines or functions +that are too difficult to cover. + +### Dependencies + +`netrics` `Depends` on `manynet` (network classes, coercion and logical tests), +`Imports` `dplyr` and `igraph` (>= 2.1.0), +and lists `autograph`, `sna` and `testthat` under `Suggests`, +so code paths depending on a suggested package must guard with `requireNamespace()` +or skip gracefully when it is unavailable. + +The declared minimum of each `stocnet` dependency is the version on CRAN, +so that CI can install it. +Where `netrics` needs something that only a newer, unreleased `manynet` has, +reach it through a shim in [R/netrics-utils.R](../R/netrics-utils.R) +rather than by raising the minimum. +Resolve the name at call time from the namespace, as `.to_linegraph()` does for +`manynet::to_linegraph()`, which was `to_ties()` before manynet 2.3.0. +Test for the function rather than for the version string, +because a pre-release development build can carry the version +without yet exporting the function. +Delete each shim once the minimum is raised past the version that added the function. ### Console messaging @@ -255,24 +436,44 @@ Note that `README.md` is generated from `README.Rmd` — edit `README.Rmd` and r The website is created by pkgdown from [pkgdown/_pkgdown.yml](../pkgdown/_pkgdown.yml), and is deployed automatically when changes reach `main`. -Please make sure that the pkgdown website will build correctly: -run `pkgdown::build_site()` locally before opening a PR. +Please make sure that the pkgdown website will build correctly before opening a PR: + +```r +pkgdown::check_pkgdown() # every topic is in the index +pkgdown::build_site(preview = FALSE) # everything else +``` + The most common failure is a new exported function that is not picked up under the function overview (the `reference:` section of `_pkgdown.yml`) — pkgdown requires *every* exported topic to appear there exactly once, or it will not build. Where possible, add functions to an existing subtitle's `starts_with()`/`contains()` pattern (e.g. a new `node_is_*()` mark or `node_in_*()` membership needs no change), and only list the topic explicitly where it does not fit a pattern. +A helper that users are not meant to call takes `@keywords internal` instead. These `reference:` titles are also the headings used in `NEWS.md` (see below), so keep the two in step. -The static pkgdown versions of the `{learnr}` tutorials, `vignettes/articles/*.Rmd`, -are generated rather than edited directly: -they are built from `inst/tutorials/*/*.Rmd` by +The `{learnr}` tutorials in `inst/tutorials/` are the source. +`vignettes/articles/*.Rmd` are their static pkgdown twins, and are *generated* from them by [data-raw/build_tutorial_articles.R](../data-raw/build_tutorial_articles.R). -After editing a tutorial, re-run that script and commit the regenerated articles; -CI checks that the two are in sync. -New tutorials also need an entry under `articles:` in `_pkgdown.yml`. +Never edit an article by hand: +the next regeneration discards the edit, +and [prchecks.yml](workflows/prchecks.yml) fails the PR for drift meanwhile. + +After adding or changing functionality, +ask whether a reader learning the package would meet it, and if so: + +1. Edit the tutorial in `inst/tutorials//*.Rmd`, + adding the function to the topic it belongs to in an `exercise=TRUE` chunk, + with a sentence saying what it is for. +2. Re-run `Rscript data-raw/build_tutorial_articles.R`, + and commit the regenerated article. +3. Run `testthat::test_file("tests/testthat/test-tutorials_netrics.R")`, + which evaluates every chunk, so new tutorial code is tested. + +A tutorial is also where a renamed function shows up as stale, +so check the tutorials whenever you rename one. +New tutorials need an entry under `articles:` in `_pkgdown.yml`. ### `NEWS.md` conventions @@ -292,10 +493,29 @@ Start each bullet with a verb matching the change type: - `Improved ...` — functional updates to existing behaviour - `Updated ...` — documentation changes +Any of these verbs can also lead a sub-bullet, +though `Improved ...` is perhaps most commonly used to cluster changes +relating to a single function. +If so, the function need only be named once, at the top; +sub-bullets will obviously relate to that function. + If a cited GitHub issue was **not** authored by @jhollway, thank the author with an `@`-tag in the bullet. -Cluster related changes (e.g. several fixes to the same function, or sub-points of one -feature) as indented sub-bullets under a lead bullet, to improve readability. + +#### Grouping + +Group first, and only then write the bullets. +The more entries a version holds, the more this matters. + +- Cluster related changes as indented sub-bullets under a lead bullet. +- Where several changes concern one function, lead with an `Improved ...` bullet naming + the function, and put the individual `Fixed ...`/`Added ...` points beneath it, + so the cluster groups by function rather than by change type. +- Under such a lead bullet, do not name the function again in the sub-bullets, + since the lead bullet already carries it. +- Where one decision runs across many functions, lead with the decision rather than + with each function, as the `decay` and measure-attribute entries do. +- Sub-bullets indent by two spaces, and nest at most one level further (four spaces). #### Writing the bullets @@ -305,13 +525,25 @@ so avoid over-punctuation or over-explanation. Details can be added to the function documentation, if necessary. - No full stop at the end of a bullet +- Keep every bullet to one line of fewer than 81 characters ideally + (a few more or less is fine) + - If a bullet wraps, it holds too much: shorten it, + or split it into a lead bullet and sub-bullets - One clause where possible, and at most one comma - - If a bullet needs a second clause to be understood, use a sub-bullet + - Use a semicolon for a short second clause, e.g. "old spelling still works but warns" + - Use a sub-bullet where the second clause needs more room than that - Name the function or object in backticks and say what changed to it, dropping scaffolding like "This change ...", "In order to ...", or "as part of an effort to" - Keep the *what*, and add the *why* only where the behaviour would otherwise look arbitrary - No trailing rationale, no restating the same change twice in different words, and no marketing adjectives such as "comprehensive" or "robust" +- A sub-bullet does not need a verb: it can state the consequence, + the previous behaviour, or an example call +- Cut a sub-bullet that only restates what the lead bullet already implies +- Where several bullets describe parallel changes, reuse the sentence structure, + so that a reader sees the parallelism at a glance +- Use one word for one thing throughout a version's entries, + rather than varying the wording for effect For example, instead of: From 88efcd48db3d4f9f021a2268188c96f73d35f845 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 10:42:06 +0200 Subject: [PATCH 59/68] Fixed various test errors for Github CI --- R/measure_cohesion.R | 2 +- R/netrics-utils.R | 20 ++++++++++++++++++++ tests/testthat/test-measure_cohesion.R | 4 ++-- tests/testthat/test-motif_composition.R | 2 +- tests/testthat/test-motif_nodes.R | 5 +++-- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/R/measure_cohesion.R b/R/measure_cohesion.R index b6fdda6..b4feaa0 100644 --- a/R/measure_cohesion.R +++ b/R/measure_cohesion.R @@ -139,7 +139,7 @@ net_by_independence <- function(.data){ # independence number of the whole network is already the quantity wanted. # The two-mode branch exists because no two nodes of one mode are ever tied # there, which would make the answer trivially the size of the larger mode. - if(manynet::is_twomode(.data) && !manynet::is_multilevel(.data)){ + if(manynet::is_twomode(.data) && !.is_multilevel(.data)){ out <- igraph::ivs_size(manynet::to_mode1(manynet::as_igraph(.data))) } else { out <- igraph::ivs_size(manynet::to_undirected(manynet::as_igraph(.data))) diff --git a/R/netrics-utils.R b/R/netrics-utils.R index 19d3c68..b9e835d 100644 --- a/R/netrics-utils.R +++ b/R/netrics-utils.R @@ -121,3 +121,23 @@ seq_nodes <- function(.data){ manynet::to_unsigned(.data, keep = "positive") } 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 +# between the modes. A network whose ties all run between the modes, as +# `ison_southern_women`'s do, is a plain two-mode network. A network whose ties +# all fall within the modes is two networks and not two levels of one. +.is_multilevel <- function(.data){ + .data <- manynet::as_igraph(.data) + # `to_multilevel()` records levels in a 'lvl' attribute and deletes 'type', + # so a network that is already converted has to be recognised by its levels. + if("lvl" %in% igraph::vertex_attr_names(.data)) + return(length(unique(igraph::vertex_attr(.data, "lvl"))) > 1) + if(!manynet::is_twomode(.data)) return(FALSE) + if(igraph::ecount(.data) == 0) return(FALSE) + type <- igraph::vertex_attr(.data, "type") + ends <- igraph::ends(.data, igraph::E(.data), names = FALSE) + between <- type[ends[,1]] != type[ends[,2]] + any(between) && any(!between) +} diff --git a/tests/testthat/test-measure_cohesion.R b/tests/testthat/test-measure_cohesion.R index 038a58b..f3c908d 100644 --- a/tests/testthat/test-measure_cohesion.R +++ b/tests/testthat/test-measure_cohesion.R @@ -102,10 +102,10 @@ test_that("net_by_independence measures a multilevel network whole", { # a multilevel network reports itself as two-mode but has ties within a # mode, so the bipartite projection it used to attempt is invalid expect_true(manynet::is_twomode(fict_actually)) - expect_true(manynet::is_multilevel(fict_actually)) + expect_true(.is_multilevel(fict_actually)) expect_values(net_by_independence(fict_actually), 76) # a genuine two-mode network still gets the projection - expect_false(manynet::is_multilevel(ison_southern_women)) + expect_false(.is_multilevel(ison_southern_women)) expect_values(net_by_independence(ison_southern_women), 2) expect_values(net_by_independence(ison_adolescents), 4) }) diff --git a/tests/testthat/test-motif_composition.R b/tests/testthat/test-motif_composition.R index 2322a8b..a095696 100644 --- a/tests/testthat/test-motif_composition.R +++ b/tests/testthat/test-motif_composition.R @@ -139,7 +139,7 @@ test_that("node_x_ties keeps every node where a layer drops some", { # nodes, so the layers must be padded back to the whole nodeset res <- node_x_ties(fict_marvel) expect_s3_class(res, "node_motif") - expect_equal(nrow(res), manynet::net_nodes(fict_marvel)) + expect_equal(nrow(res), as.integer(manynet::net_nodes(fict_marvel))) expect_equal(colnames(res), c("relationship", "affiliation", "Diversity")) expect_false(all(is.na(res[, "Diversity"]))) }) diff --git a/tests/testthat/test-motif_nodes.R b/tests/testthat/test-motif_nodes.R index 96cdb3e..8ee72f0 100644 --- a/tests/testthat/test-motif_nodes.R +++ b/tests/testthat/test-motif_nodes.R @@ -105,13 +105,14 @@ test_that("node_x_tie finds layers whatever the tie attribute is called", { # ison_monks multiplexes on "layer", so reading "type" gave it no layers res <- node_x_tie(ison_monks) expect_s3_class(res, "node_motif") - expect_equal(nrow(res), manynet::net_nodes(ison_monks)) + expect_equal(nrow(res), as.integer(manynet::net_nodes(ison_monks))) expect_s3_class(node_x_tie(ison_algebra), "node_motif") }) test_that("node_x_tie reports layers that cannot be stacked", { # fict_marvel's layers hold different node sets, so no one census spans them - withr::local_options(snet_verbosity = "verbose") + old <- options(snet_verbosity = "verbose") + on.exit(options(old)) expect_error(node_x_tie(fict_marvel), "node set") }) From 773ddff074fb77250b2648733f6f45d7463bb3a4 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 11:26:41 +0200 Subject: [PATCH 60/68] Avoided polluting test output with console output --- tests/testthat/helper-netrics.R | 28 ++++++++++++++++++++-- tests/testthat/test-member_community.R | 32 +++++++++++++------------- tests/testthat/test-motif_nodes.R | 6 ++--- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/tests/testthat/helper-netrics.R b/tests/testthat/helper-netrics.R index 1605f0f..e0f30c5 100644 --- a/tests/testthat/helper-netrics.R +++ b/tests/testthat/helper-netrics.R @@ -1,6 +1,19 @@ options(manynet_verbosity = "quiet") options(snet_verbosity = "quiet") +# The suite runs quiet, so a test that asserts on a message has to turn the +# messages on. `local_verbose()` does that for one test alone, and puts the +# options back when that test ends, whether it passes or fails. A test that +# sets the option itself leaves it set on a failure, and every later test then +# prints its messages to the console. +local_verbose <- function(env = parent.frame()){ + old <- options(manynet_verbosity = "verbose", snet_verbosity = "verbose") + do.call(base::on.exit, + list(substitute(options(OLD), list(OLD = old)), add = TRUE), + envir = env) + invisible(old) +} + expect_values <- function(object, ref, toler = 3) { # 1. Capture object and label # act <- quasi_label(rlang::enquo(object), arg = "object") @@ -149,6 +162,15 @@ check_tute_functions <- function(path, skip = "ergm\\("){ exprs <- parse(text = extract_rmd_code(path)) env <- new.env(parent = globalenv()) + # A tutorial may call `?fn`. `help()` prints through a pager that writes + # straight to the terminal, which `capture.output()` cannot take. A pager + # that does nothing keeps that page off the console. + op <- options(pager = function(files, header, title, delete.file){ + if(delete.file) unlink(files) + invisible(NULL) + }) + on.exit(options(op), add = TRUE) + is_skipped_call <- function(expr) { any(grepl(skip, deparse(expr))) } @@ -166,7 +188,9 @@ check_tute_functions <- function(path, skip = "ergm\\("){ e <- NULL m <- NULL - not_out <- withCallingHandlers( + # A tutorial chunk may print a result. `capture.output()` keeps that off + # the console, so a test run reports expectations and nothing else. + not_out <- utils::capture.output(withCallingHandlers( tryCatch( eval(exprs[[i]], envir = env), error = function(err) { @@ -182,7 +206,7 @@ check_tute_functions <- function(path, skip = "ergm\\("){ m <<- c(m, conditionMessage(msg)) invokeRestart("muffleMessage") } - ) + )) # If there *was* a warning, check if it's a deprecated/defunct one if (!is.null(w)) { diff --git a/tests/testthat/test-member_community.R b/tests/testthat/test-member_community.R index 728c7c3..bddd313 100644 --- a/tests/testthat/test-member_community.R +++ b/tests/testthat/test-member_community.R @@ -24,13 +24,13 @@ test_that("node_walktrap algorithm works", { }) test_that("node_in_community uses node_in_optimal on small networks", { - skip_if(format(Sys.time(), "%H") >= "09") - options(manynet_verbosity = "verbose") - options(snet_verbosity = "verbose") - expect_message(node_in_community(manynet::create_ring(10)), "optimal") - expect_message(node_in_community(manynet::create_ring(200)), "xcluding") - options(manynet_verbosity = "quiet") - options(snet_verbosity = "quiet") + local_verbose() + # `capture_messages()` takes every message, so none reaches the console. + # `expect_message()` takes only the first, and lets the rest print. + expect_match(capture_messages(node_in_community(manynet::create_ring(10))), + "optimal", all = FALSE) + expect_match(capture_messages(node_in_community(manynet::create_ring(200))), + "xcluding", all = FALSE) }) test_that("label propagation membership works", { # stochastic, so assert on structure rather than exact labels @@ -95,13 +95,13 @@ test_that("k accepts the selection methods", { }) test_that("an unreachable k warns and returns the nearest", { - options(snet_verbosity = "verbose") + 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_message(node_in_betweenness(unconn, k = 1), "communities") - expect_equal(length(unique(node_in_betweenness(unconn, k = 1))), 2) - options(snet_verbosity = "quiet") + 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) }) test_that("node_in_partition preserves its two-group result", { @@ -144,11 +144,11 @@ test_that("node_in_community consensus accepts k", { }) test_that("node_in_community ignores consensus where optimal is available", { - options(snet_verbosity = "verbose") + local_verbose() small <- manynet::create_ring(10) # snet_info() signals a cli message, not an R condition - expect_message(node_in_community(small, consensus = TRUE), "Ignoring") - expect_equal(as.character(node_in_community(small, consensus = TRUE)), - as.character(node_in_optimal(small))) - options(snet_verbosity = "quiet") + expect_match(capture_messages(node_in_community(small, consensus = TRUE)), + "Ignoring", all = FALSE) + expect_equal(as.character(suppressMessages(node_in_community(small, consensus = TRUE))), + as.character(suppressMessages(node_in_optimal(small)))) }) diff --git a/tests/testthat/test-motif_nodes.R b/tests/testthat/test-motif_nodes.R index 8ee72f0..42be452 100644 --- a/tests/testthat/test-motif_nodes.R +++ b/tests/testthat/test-motif_nodes.R @@ -111,9 +111,9 @@ test_that("node_x_tie finds layers whatever the tie attribute is called", { test_that("node_x_tie reports layers that cannot be stacked", { # fict_marvel's layers hold different node sets, so no one census spans them - old <- options(snet_verbosity = "verbose") - on.exit(options(old)) - expect_error(node_x_tie(fict_marvel), "node set") + local_verbose() + # the call also reports its coercion, so the messages are taken as well + expect_error(suppressMessages(node_x_tie(fict_marvel)), "node set") }) test_that("node_x_triad reaches the mixed census through net_x_triad", { From 97aebe349d9687c4921059ff46b8e97103b4d0af Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 11:37:33 +0200 Subject: [PATCH 61/68] Skip tutorial testing on CRAN --- tests/testthat/test-tutorials_netrics.R | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/testthat/test-tutorials_netrics.R b/tests/testthat/test-tutorials_netrics.R index 775e09a..b7a0319 100644 --- a/tests/testthat/test-tutorials_netrics.R +++ b/tests/testthat/test-tutorials_netrics.R @@ -1,4 +1,8 @@ test_that("netrics tutorial code runs without warnings or errors", { + # Running every chunk of the four tutorials takes about twenty minutes, which + # is the whole of this package's check time. The tutorials exercise the same + # functions that the other test files cover, so CRAN gains little from them. + skip_on_cran() skip_if_not_installed("netrics", minimum_version = "0.2.2") for(tute in find_pkg_tutorial_paths("netrics")){ expect_null(check_tute_functions(tute), From 46249aedca3adb4645b1cab596627f079585f46f Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 13:31:05 +0200 Subject: [PATCH 62/68] Cleaned up some easy documentation --- DESCRIPTION | 2 +- NEWS.md | 97 ++++++++++--------- R/measure_centrality_between.R | 4 +- R/measure_centrality_closeness.R | 4 +- R/measure_centrality_degree.R | 11 ++- R/measure_centrality_eigen.R | 4 +- R/measure_change.R | 4 + R/measure_features.R | 3 +- R/measure_holes.R | 2 + R/member_cliques.R | 15 +-- R/member_community.R | 10 +- R/member_core.R | 5 +- R/member_equivalence.R | 11 ++- R/motif_brokerage.R | 4 +- R/motif_census.R | 10 ++ R/motif_composition.R | 7 ++ README.Rmd | 29 +++--- README.md | 38 +++++--- man-roxygen/param_times.R | 6 ++ man/mark_degree.Rd | 4 +- man/mark_dyads.Rd | 8 +- man/mark_select_tie.Rd | 8 +- man/mark_ties.Rd | 8 +- man/mark_triangles.Rd | 8 +- man/measure_assort_net.Rd | 8 +- man/measure_assort_node.Rd | 8 +- man/measure_breadth.Rd | 8 +- man/measure_broker_node.Rd | 8 +- man/measure_broker_tie.Rd | 19 ++-- man/measure_brokerage.Rd | 8 +- man/measure_central_between.Rd | 22 ++--- man/measure_central_close.Rd | 22 ++--- man/measure_central_degree.Rd | 32 +++--- man/measure_central_eigen.Rd | 22 ++--- ...ween.Rd => measure_central_tie_between.Rd} | 24 ++--- ..._close.Rd => measure_central_tie_close.Rd} | 24 ++--- ...egree.Rd => measure_central_tie_degree.Rd} | 24 ++--- ..._eigen.Rd => measure_central_tie_eigen.Rd} | 24 ++--- man/measure_centralisation_between.Rd | 12 +-- man/measure_centralisation_close.Rd | 12 +-- man/measure_centralisation_degree.Rd | 12 +-- man/measure_centralisation_eigen.Rd | 12 +-- man/measure_closure.Rd | 8 +- man/measure_closure_node.Rd | 8 +- man/measure_cohesion.Rd | 8 +- man/measure_core.Rd | 8 +- man/measure_diffusion_infection.Rd | 8 +- man/measure_diffusion_net.Rd | 8 +- man/measure_diffusion_node.Rd | 8 +- man/measure_diverse_net.Rd | 8 +- man/measure_diverse_node.Rd | 8 +- man/measure_features.Rd | 8 +- man/measure_fit.Rd | 8 +- man/measure_fragmentation.Rd | 8 +- man/measure_hierarchy.Rd | 8 +- man/measure_periods.Rd | 11 ++- man/member_brokerage.Rd | 5 +- man/member_cliques.Rd | 24 +++-- man/member_community.Rd | 13 ++- man/member_community_non.Rd | 8 +- man/motif_composition.Rd | 10 ++ man/motif_net.Rd | 8 ++ man/motif_path.Rd | 8 ++ man/motif_periods.Rd | 3 + pkgdown/_pkgdown.yml | 25 ++--- 65 files changed, 479 insertions(+), 343 deletions(-) create mode 100644 man-roxygen/param_times.R rename man/{measure_centralities_between.Rd => measure_central_tie_between.Rd} (89%) rename man/{measure_centralities_close.Rd => measure_central_tie_close.Rd} (87%) rename man/{measure_centralities_degree.Rd => measure_central_tie_degree.Rd} (87%) rename man/{measure_centralities_eigen.Rd => measure_central_tie_eigen.Rd} (87%) diff --git a/DESCRIPTION b/DESCRIPTION index e55777a..c8a656c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,5 +1,5 @@ Package: netrics -Title: Many Ways to Measure and Classify Membership for Networks, Nodes, and Ties +Title: Many Marks, Measures, Memberships, and Motifs for Networks Version: 1.0.0 Description: Many tools for calculating network, node, or tie marks, measures, motifs and memberships of many different types of networks. diff --git a/NEWS.md b/NEWS.md index 8214fbf..6e465ae 100644 --- a/NEWS.md +++ b/NEWS.md @@ -3,7 +3,7 @@ ## Package - Removed CRAN version check from `.onAttach()` making `library(netrics)` faster to attach -- Fixed release workflow referring to `actions/actions/checkout`; the doubled path segment would have failed every step using it +- Fixed release workflow doubling `actions/actions/checkout` path segment - Added `param_cutoff` roxygen template, correctly documenting geodesic cutoff for six functions - Added `param_decay` roxygen template, correctly documenting decay parameter - Updated GitHub Actions workflows to latest major action versions @@ -12,10 +12,10 @@ ## Measures - Improved `make_*_measure()` to record algorithm details so results can be read without the script/manual - - `measure`, the measure actually calculated, e.g. `node_by_degree()` reports "strength centrality" on a weighted network + - `measure` actually calculated, e.g. `node_by_degree()` is "strength centrality" on a weighted network - `normalization`, one of `"normalized"`, `"scaled"`, `"proportional"` or `"none"` - `range`, the theoretical range of the returned values - - `variant`, which variant was computed where a measure offers a choice, e.g. `net_by_reciprocity()` reports "ratio" when asked for the ratio + - `variant` computed where a measure offers a choice, e.g. `net_by_reciprocity()` reports "ratio" when asked - Printing is a companion change in `{manynet}`, which defaults to previous behavior - Added `measure`, `range`, `normalization`, and `variant` reporting to every measure where applicable - Updated documentation such that measures that functions and certain arguments produce are discoverable by name @@ -28,9 +28,9 @@ - `node_by_transitivity()` as the local clustering coefficient - `tie_by_betweenness()` as edge betweenness - `node_by_subgraph()` as a node's contribution to the Estrada index - - `node_by_induced()` and `node_by_vitality()` are the betweenness and closeness instances of Latora and Marchiori's delta centrality, and cross-referenced them to each other - - `node_by_information()` is the closeness member of the current-flow family, whose betweenness member netrics does not yet offer - - Stopped `node_by_induced()` also calling itself "vitality centrality", which collided with `node_by_vitality()` + - `node_by_induced()` and `node_by_vitality()` as delta centrality + - `node_by_information()` as the closeness member of the current-flow family + - Stopped `node_by_induced()` also calling itself "vitality centrality" - Improved specificity of arguments, separating normalising from scaling - Renamed `scale` argument to `scaled`; old spelling still works but warns - Corrected claim that all measures return normalized values by default @@ -51,21 +51,25 @@ - Fixed how `node_by_vitality()` treats cut nodes - Unnormalised returns `-Inf` for cut nodes as the Wiener index definition requires - Normalised rescales finite scores onto `[0,1]` and places cut nodes at 0 -- Fixed `net_by_efficiency()` to implement Krackhardt's definition as share of possible excess ties a network leaves unused - - Previously unbounded `(n-1)/sum(indegree)`, so now `net_x_hierarchy()` compares efficiency against three quantities already on `[0,1]` +- Fixed `net_by_efficiency()` to implement Krackhardt's share of excess ties + - `net_x_hierarchy()` now compares four quantities already on `[0,1]` - Fixed `net_by_immunity()` returning a negative herd immunity threshold when \eqn{R < 1} -- Fixed `net_by_density()`, `net_by_equivalency()` and `node_by_reciprocity()` summing tie weights where they should have counted ties +- Fixed `net_by_density()`, `net_by_equivalency()` and `node_by_reciprocity()` summing tie weights - Improved `node_by_closeness()` to validate `direction` via `match.arg()` - Removed `direction` from `net_by_betweenness()` which never used it -- Moved `node_by_posneg()` to the eigenvector doc group as a Katz matrix-inversion walk-based measure for signed networks +- Moved `node_by_posneg()` to the eigenvector doc group - Improved `node_by_subgraph()` - Now honours tie weights - Added `method=` to choose which closed walks to count: `"odd"`, `"even"`, or`"all"` (default, both) - Updated references in centrality documentation - - Corrected `node_by_eigenvector()` to cite Bonacich (1972) as the origin of the measure, rather than only Bonacich (1991) - - Added Freeman (1978) to `node_by_degree()` and the centralisation functions, the source of the centralisation index they apply - - Added references to `tie_by_betweenness()`, which had none - - Added Sabidussi (1966) to closeness, Boldi and Vigna (2014) to harmonic, Borgatti and Everett (2006) to reach, Brandes (2008) and Ercsey-Ravasz et al. (2012) to betweenness, Watts and Strogatz (1998) and Holland and Leinhardt (1971) to node transitivity, and Page et al. (1999) to pagerank + - Corrected `node_by_eigenvector()` to cite Bonacich (1972), not only (1991) + - Added Freeman (1978) to `node_by_degree()` and the centralisation functions + - Added Sabidussi (1966) to closeness + - Added Boldi and Vigna (2014) to harmonic + - Added Borgatti and Everett (2006) to reach + - Added Brandes (2008) and Ercsey-Ravasz et al. (2012) to betweenness + - Added Watts/Strogatz (1998) and Holland/Leinhardt (1971) to node transitivity + - Added Page et al. (1999) to pagerank - Added `net_by_bipartivity()` for how close a network is to being bipartite - Added `net_by_cyclicality()` for detecting generalised exchange - Added `net_by_compactness()` for the average closeness of all pairs of nodes @@ -75,31 +79,31 @@ - Ideal types are `nul`, `com`, `reg`, `rdo`, `cdo` and `dnc` - Generalises `net_by_factions()` beyond structural equivalence - Fixed `node_by_equivalency()` erroring on any network, despite being documented for the two-mode case -- Fixed `node_by_diversity()` reporting an undefined object in its message about substituting an inapplicable index +- Fixed `node_by_diversity()` reporting undefined objects when substituting an inapplicable index - Fixed `net_by_transmissibility()` declaring itself a proportion -- Fixed `net_by_balance()` erroring on networks that hold signs as negative weights, which is how 'stocnet' objects keep them -- Fixed `net_by_diameter()`, `net_by_length()`, and `net_by_compactness()` erroring on networks holding signs as negative weights, which were read as a distance - - These measures now consider only the positive ties +- Fixed `net_by_balance()` erroring on networks holding signs as negative weights +- Fixed `net_by_diameter()`, `net_by_length()` and `net_by_compactness()` on signed networks + - Now consider only positive ties for distances - Fixed `node_by_reciprocity()` to return 1 throughout for any undirected network -- Fixed `node_by_information()` on rectangular incidence matrices by flattening with `manynet::to_multilevel()` +- Fixed `node_by_information()` on rectangular matrices by using `manynet::to_multilevel()` - Fixed `net_by_independence()` erroring on multilevel networks by measuring whole ## Memberships - Added `k=` to community detection functions to target a specific number of communities (thanks @tomasdiviak) - - `node_in_betweenness()`, `node_in_greedy()`, `node_in_eigen()`, and `node_in_walktrap()` cut their dendrograms at `k` + - Hierarchical algorithms cut their dendrograms at `k` - `node_in_louvain()` and `node_in_leiden()` search the resolution parameter for the value that returns `k` - `node_in_fluid()` passes `k` straight to the algorithm, which also makes it much faster - `node_in_labels()` seeds `k` fixed labels and merges any surplus groups by modularity - `node_in_partition()` is now a k-way Kernighan-Lin, and no longer returns only two groups - `node_in_community()` considers only these algorithms when `k` is given - `k` also accepts `"silhouette"`, `"elbow"`, and `"strict"`, as in `node_in_equivalence()` - - Note `k=` is now the second argument, so positional calls such as `node_in_louvain(x, 0.5)` must become `node_in_louvain(x, resolution = 0.5)` -- Fixed `node_in_fluid()` and `node_in_spinglass()` returning nothing on a disconnected network, now abort loudly + - Note `k=` is now positioned second, so positional calls must name arguments +- Fixed `node_in_fluid()` and `node_in_spinglass()` aborting silently on disconnected networks - Added `node_in_labels()` for label propagation community detection -- Renamed `times=` in `node_in_walktrap()` to `steps=`, which is more descriptive and consistent with `{igraph}` +- Renamed `times=` in `node_in_walktrap()` to `steps=` - Added `consensus=` to `node_in_community()` for combining partitions of all applicable algorithms - - Runs each algorithm (stochastic ones `times`), then converges on how often each pair of nodes is grouped together + - Runs each algorithm (stochastic ones `times`), then converges on common groupings - `consensus = FALSE` default, and ignored where network small enough for `node_in_optimal()` - Fixed returning nothing but an error whenever verbosity was not `"verbose"` - Renamed `node_by_coreness()` to `node_by_core()` @@ -107,16 +111,18 @@ - Fixed it returning identical scores for a directed network and its reverse - Fixed it erroring on two-mode networks whose modes are of unequal size - Improved `node_in_core()` - - Renamed `centrality=` to `coreness=`: `"rich"` default for weighted, directed, or two-mode networks, - `"correlation"` otherwise - - Adds `direction=` for directed networks, adding `"Sender"` for core out-ties and periphery in-ties and - `"Receiver"` for core in-ties and periphery out-ties + - Renamed `centrality=` to `coreness=` + - `"rich"` by default for weighted, directed or two-mode networks + - `"correlation"` otherwise + - Adds `direction=` for directed networks + - `"Sender"` for core out-ties and periphery in-ties + - `"Receiver"` for core in-ties and periphery out-ties - Fixed sorting numbered middle labels alphabetically or from arbitrary cluster numbers -- Added `node_in_block()` for direct blockmodelling, searching partitions for the one that minimises `net_by_inconsistency()` -- Fixed `node_in_regular()` to compute regular equivalence using recursive similarity between nodes rather than a triad census - - Choose between `regularity = "rolesim"` (default) and `"rege"` +- Added `node_in_block()` for direct blockmodelling for partitions that minimise `net_by_inconsistency()` +- Fixed `node_in_regular()` to compute regular equivalence correctly + - Choose between `regularity = "rolesim"` (default) and `"rege"` for recursive similarity - Note existing scripts calling `node_in_regular()` will now return more correct results - - Moved former behaviour to `node_in_motif()`, documented as capturing similarity of local embedding rather than role equivalence + - Moved former similarity of local embedding to `node_in_motif()` ## Motifs @@ -128,27 +134,24 @@ - It branches on two-mode networks to find bicliques (closes #8, thanks @noortjemay) - Note that it considers only positive ties, since a clique is a cohesive subgroup - Improved `node_x_tie()` - - Fixed erroring on diffusion models, which downstream affected `node_in_equivalence()` and `node_in_structural()` + - Fixed erroring on diffusion models which affected `node_in_equivalence()` and `node_in_structural()` - Fixed erroring on any multiplex network not multiplexed on a `type` tie attribute - Added `node_x_ties()`, describing the distribution of each node's tie values - In a multiplex network it describes their spread across layers -- Added `node_x_alters()` and `node_x_similarity()`, describing the composition of each node's alters and their similarity to it - - Each branches on whether the attribute given is categorical or continuous - - For two-mode networks, `node_x_similarity()` compares each node with those at distance two - - These are the nodes it shares a node of the other mode with, following the tertius effect of `{migraph}` and `{goldfish}` (Haunss and Hollway 2023) -- Added `net_x_homophily()`, returning the table behind the EI index together with an expected-EI baseline and Yule's Q - - Note that on weighted networks this counts ties where `net_by_heterophily()` sums weights, so the two agree only when unweighted +- Added `node_x_alters()` for describing composition of each node's alters +- Added `node_x_similarity()` for describing similarity of each node to its alters + - For two-mode networks, compares each node with those at distance two +- Added `net_x_homophily()` for the table behind the EI index against expected baseline ## Methods - Added `regularity_rolesim()` and `regularity_rege()`, recursive role similarity methods - Note `regularity_rege()` is degenerate on unweighted connected networks, where it warns - Added coreness methods for core-periphery analysis, each returning mark, member, and measure - - `coreness_correlation()` is Borgatti and Everett's continuous model, fixed to exclude self-ties and to start its search from the degree ordering rather than from a flat vector, where the correlation is undefined - - `coreness_rich()` is Ma and Mondragon's rich-core, which reads tie weights and tie direction directly, and is the only method that runs on a two-mode network - - Note this is not the rich club that `net_by_richclub()` measures: a rich core need not be densely tied, and needs no null model - - `coreness_transition()` is Rombach and colleagues' core score, aggregated over a grid of boundary sharpness and core size - - `coreness_hub()` is Elliott and colleagues' directed core-periphery, distinguishing an out-core from an in-core + - `coreness_correlation()` is Borgatti and Everett's continuous model, fixed to exclude self-ties + - `coreness_rich()` is Ma and Mondragon's rich-core for directed and two-mode networks + - `coreness_hub()` is Elliott and colleagues' more granualr directed core-periphery + - `coreness_transition()` is Rombach and colleagues' core score over boundary sharpness and core size ## Tutorials @@ -245,7 +248,7 @@ - Added network analysis tutorials from `{manynet}` -## Members +## Memberships - Added more explanation for `node_in_partition()` @@ -260,7 +263,7 @@ consistent function documentation. - Fixed startup messages. -## Measuring +## Measures - Renamed `node_adoption_time()` to `node_by_adopt_time()` - Renamed `node_thresholds()` to `node_by_adopt_threshold()` @@ -272,7 +275,7 @@ - Updated and separated brokerage, diversity/assortativity, cohesion, closure, cliques, components, features, and hierarchy documentation by level. -## Members +## Memberships - Separated `node_in_community()` documentation from the hierarchical and non-hierarchical community-detection algorithms. diff --git a/R/measure_centrality_between.R b/R/measure_centrality_between.R index 1924f3b..dde5007 100644 --- a/R/measure_centrality_between.R +++ b/R/measure_centrality_between.R @@ -205,7 +205,7 @@ node_by_stress <- function(.data, normalized = TRUE){ # Tie betweenness centrality #### #' Measuring ties betweenness-like centrality -#' @name measure_centralities_between +#' @name measure_central_tie_between #' @description #' `tie_by_betweenness()` measures the number of shortest paths going through a tie. #' @@ -222,7 +222,7 @@ node_by_stress <- function(.data, normalized = TRUE){ #' @template tie_measure NULL -#' @rdname measure_centralities_between +#' @rdname measure_central_tie_between #' @section Edge betweenness centrality: #' The betweenness centrality of a tie, also known as _edge betweenness_, #' counts the shortest paths between other nodes that run along it. diff --git a/R/measure_centrality_closeness.R b/R/measure_centrality_closeness.R index 69cb739..d4b35b9 100644 --- a/R/measure_centrality_closeness.R +++ b/R/measure_centrality_closeness.R @@ -546,7 +546,7 @@ node_by_randomwalk <- function(.data, normalized = TRUE){ # Tie closeness centrality #### #' Measuring ties closeness-like centrality -#' @name measure_centralities_close +#' @name measure_central_tie_close #' @description #' `tie_by_closeness()` measures the closeness of each tie to other ties #' in the network. @@ -564,7 +564,7 @@ node_by_randomwalk <- function(.data, normalized = TRUE){ #' @template tie_measure NULL -#' @rdname measure_centralities_close +#' @rdname measure_central_tie_close #' @examples #' (ec <- tie_by_closeness(ison_adolescents)) #' ison_adolescents |> mutate_ties(weight = ec) diff --git a/R/measure_centrality_degree.R b/R/measure_centrality_degree.R index 7ccf5c5..e07e9a8 100644 --- a/R/measure_centrality_degree.R +++ b/R/measure_centrality_degree.R @@ -28,6 +28,13 @@ #' `node_by_multidegree()` is the one measure here that is not reached by #' dispatch: a multiplex network does not itself say _which_ two types of #' tie to contrast, so `tie1` and `tie2` must be named. +#' @section Multiplex networks: +#' `node_by_degree()` counts every tie a node holds, whatever its layer, +#' so a node tied twice to the same alter on two layers scores 2. +#' To score one layer at a time, take it first with +#' [manynet::to_uniplex()], or use `node_by_multidegree()` to contrast two. +#' Note that `to_uniplex()` drops the nodes that hold none of the retained +#' ties, so scores from two layers are of different lengths. #' @template param_data #' @template param_norm #' @template param_dir @@ -230,7 +237,7 @@ node_by_leverage <- function(.data){ # Degree-like centralities #### #' Measuring ties degree-like centrality -#' @name measure_centralities_degree +#' @name measure_central_tie_degree #' @description #' `tie_by_degree()` measures the degree centrality of ties in a network #' @@ -247,7 +254,7 @@ node_by_leverage <- function(.data){ #' @template tie_measure NULL -#' @rdname measure_centralities_degree +#' @rdname measure_central_tie_degree #' @examples #' tie_by_degree(ison_adolescents) #' @export diff --git a/R/measure_centrality_eigen.R b/R/measure_centrality_eigen.R index 17bea20..587b9fe 100644 --- a/R/measure_centrality_eigen.R +++ b/R/measure_centrality_eigen.R @@ -428,7 +428,7 @@ node_by_posneg <- function(.data){ # Eigenvector-like centralities #### #' Measuring ties eigenvector-like centrality -#' @name measure_centralities_eigen +#' @name measure_central_tie_eigen #' @description #' `tie_by_eigenvector()` measures the eigenvector centrality of ties in a #' network. @@ -446,7 +446,7 @@ node_by_posneg <- function(.data){ #' @template tie_measure NULL -#' @rdname measure_centralities_eigen +#' @rdname measure_central_tie_eigen #' @examples #' tie_by_eigenvector(ison_adolescents) #' @export diff --git a/R/measure_change.R b/R/measure_change.R index 978a021..e60f093 100644 --- a/R/measure_change.R +++ b/R/measure_change.R @@ -11,6 +11,8 @@ NULL #' @rdname measure_periods +#' @examples +#' net_by_waves(ison_classmates) #' @export net_by_waves <- function(.data){ .data <- manynet::expect_nodes(.data) @@ -45,6 +47,8 @@ NULL #' @rdname motif_periods #' @param object2 A network object. +#' @examples +#' net_x_change(ison_monks) #' @export net_x_change <- function(.data, object2){ net <- manynet::expect_nodes(.data) diff --git a/R/measure_features.R b/R/measure_features.R index 014bd10..eb25797 100644 --- a/R/measure_features.R +++ b/R/measure_features.R @@ -294,7 +294,8 @@ net_by_balance <- function(.data) { } tmat <- t(matrix(igraph::triangles(g), nrow = 3)) if (nrow(tmat) == 0) { - warning("g does not contain any triangles") + manynet::snet_warn("The network contains no triangles,", + "so every signed triad count is 0.") return(c(`+++` = 0, `++-` = 0, `+--` = 0, `---` = 0)) } emat <- t(apply(tmat, 1, function(x) c(igraph::get_edge_ids(g, diff --git a/R/measure_holes.R b/R/measure_holes.R index 9276900..b09f6d7 100644 --- a/R/measure_holes.R +++ b/R/measure_holes.R @@ -259,6 +259,8 @@ node_by_neighbours_degree <- function(.data){ NULL #' @rdname measure_broker_tie +#' @examples +#' tie_by_cohesion(ison_adolescents) #' @export tie_by_cohesion <- function(.data){ .data <- manynet::expect_ties(.data) diff --git a/R/member_cliques.R b/R/member_cliques.R index 8af248d..d428ad6 100644 --- a/R/member_cliques.R +++ b/R/member_cliques.R @@ -37,13 +37,14 @@ NULL #' @param group_size An integer indicating the desired size of most of the groups. #' Note that if the number of nodes is not divisible into groups of equal size, #' there may be some larger or smaller groups. -#' @param times An integer of the number of search iterations the algorithm should complete. -#' By default this is the number of nodes in the network multiplied by the number of groups. +#' @template param_times +#' @details +#' `times` defaults to the number of nodes multiplied by the number of groups. #' This heuristic may be insufficient for small networks and numbers of groups, -#' and burdensome for large networks and numbers of groups, but can be overwritten. -#' At every 10th iteration, a stronger perturbation of a number of successive changes, -#' approximately the number of nodes divided by the number of groups, -#' will take place irrespective of whether it improves the objective function. +#' and burdensome for large ones, but can be overwritten. +#' At every 10th iteration, a stronger perturbation of a number of successive +#' changes, approximately the number of nodes divided by the number of groups, +#' takes place whether or not it improves the objective function. #' @references #' ## On the maximally diverse grouping problem #' Lai, Xiangjing, and Jin-Kao Hao. 2016. @@ -55,6 +56,8 @@ NULL #' “Neighborhood Decomposition Based Variable Neighborhood Search and Tabu Search for Maximally Diverse Grouping.” #' _European Journal of Operational Research_ 289(3):1067–86. #' \doi{10.1016/j.ejor.2020.07.048}. +#' @examples +#' node_in_roulette(ison_adolescents, num_groups = 3) #' @export node_in_roulette <- function(.data, num_groups, group_size, times = NULL){ .data <- manynet::expect_nodes(.data) diff --git a/R/member_community.R b/R/member_community.R index af71526..d6d78ca 100644 --- a/R/member_community.R +++ b/R/member_community.R @@ -259,9 +259,10 @@ consensus_memb <- function(.data, k, Kmax, times, threshold = 0.5, iter = 10){ #' This argument is ignored on a network small enough for #' `node_in_optimal()`, which already returns the maximum modularity #' partition. -#' @param times An integer of how many times each stochastic algorithm is run -#' when `consensus = TRUE`. By default 20. Deterministic algorithms are run -#' once however this is set. +#' @template param_times +#' @details +#' `times` applies only when `consensus = TRUE`, and is 20 by default. +#' Deterministic algorithms are run once however it is set. #' @family community #' @template node_member #' @references @@ -467,8 +468,7 @@ kl_partition <- function(g, n, k, rounds = 50){ #' a grouping that provides the shortest description length for a random walk, #' where the description length is measured by the expected number of bits #' per node required to encode the path. -#' @param times Integer indicating number of simulations/walks used. -#' By default, `times=50`. +#' @template param_times #' @references #' ## On infomap community detection #' Rosvall, M, and C. T. Bergstrom. 2008. diff --git a/R/member_core.R b/R/member_core.R index 0834354..2708297 100644 --- a/R/member_core.R +++ b/R/member_core.R @@ -204,8 +204,9 @@ node_in_core <- function(.data, groups = 3, .data <- manynet::expect_nodes(.data) direction <- match.arg(direction) if(direction == "both") return(.core_four_sets(.data)) - if (groups < 2) manynet::snet_abort("Number of categories must be at least 2") - if (groups > manynet::net_nodes(.data)) manynet::snet_abort("There cannot be more categories than nodes.") + if (groups < 2) manynet::snet_abort("{.arg groups} must be at least 2.") + if (groups > manynet::net_nodes(.data)) + manynet::snet_abort("{.arg groups} cannot exceed the number of nodes.") contin <- as.numeric(node_by_core(.data, coreness = coreness, direction = direction)) cluster_by <- match.arg(cluster_by) diff --git a/R/member_equivalence.R b/R/member_equivalence.R index dd6f446..a619982 100644 --- a/R/member_equivalence.R +++ b/R/member_equivalence.R @@ -57,18 +57,23 @@ node_in_equivalence <- function(.data, motif, "canberra", "binary", "minkowski"), Kmax = 8L){ .data <- manynet::expect_nodes(.data) - hc <- switch(match.arg(cluster), + cluster <- match.arg(cluster) + manynet::snet_info("Clustering using {.fn cluster_{cluster}}.") + hc <- switch(cluster, hierarchical = cluster_hierarchical(motif, match.arg(distance)), concor = cluster_concor(.data, motif), cosine = cluster_cosine(motif, match.arg(distance))) - if(!is.numeric(k)) - k <- switch(match.arg(k), + if(!is.numeric(k)){ + k <- match.arg(k) + manynet::snet_info("Selecting the number of clusters using {.fn k_{k}}.") + k <- switch(k, strict = k_strict(hc, .data), elbow = k_elbow(hc, .data, motif, Kmax), silhouette = k_silhouette(hc, .data, Kmax)) + } if(length(k)==0) k <- 1 # in the case of all nodes being in the same cluster out <- make_node_member(stats::cutree(hc, k), .data) diff --git a/R/motif_brokerage.R b/R/motif_brokerage.R index df4b538..6b68a38 100644 --- a/R/motif_brokerage.R +++ b/R/motif_brokerage.R @@ -181,7 +181,7 @@ node_by_brokering_exclusivity <- function(.data, membership){ #' Memberships in brokerage positions #' #' @description -#' `node_in_brokerage()` returns nodes membership as a powerhouse, +#' `node_in_brokering()` returns nodes membership as a powerhouse, #' connector, linchpin, or sideliner according to Hamilton et al. (2020). #' #' @name member_brokerage @@ -199,6 +199,8 @@ node_by_brokering_exclusivity <- function(.data, membership){ NULL #' @rdname member_brokerage +#' @examples +#' node_in_brokering(ison_networkers, "Discipline") #' @export node_in_brokering <- function(.data, membership){ .data <- manynet::expect_nodes(.data) diff --git a/R/motif_census.R b/R/motif_census.R index 6d79214..e3bffb9 100644 --- a/R/motif_census.R +++ b/R/motif_census.R @@ -12,6 +12,11 @@ #' - `node_x_path()` returns the shortest path lengths #' of each node to every other node in the network. #' +#' @section Multiplex networks: +#' `node_x_tie()` binds the layers together, giving one block of columns +#' per layer, whatever attribute the network is multiplexed on. +#' Each block stays the length of the whole nodeset. +#' To census one layer alone, take it first with [manynet::to_uniplex()]. #' @template param_data #' @template node_motif #' @importFrom igraph vcount make_ego_graph delete_vertices triad_census @@ -332,6 +337,11 @@ node_x_tetrad <- function(.data){ #' #' See also \href{https://www.graphclasses.org/smallgraphs.html}{graph classes}. #' +#' @section Multiplex networks: +#' `net_x_triad()` takes the mixed census on a multiplex network, +#' splitting it into layers by mode rather than by position. +#' To census one layer alone, take it first with [manynet::to_uniplex()]. +#' `net_x_dyad()` and `net_x_tetrad()` count every tie whatever its layer. #' @template param_data #' @family cohesion #' @template net_motif diff --git a/R/motif_composition.R b/R/motif_composition.R index b5f14de..a0130d8 100644 --- a/R/motif_composition.R +++ b/R/motif_composition.R @@ -20,6 +20,13 @@ #' Each branches internally on the type of network or attribute given, #' so the same function serves weighted, multiplex, and two-mode networks, #' and categorical as well as continuous attributes. +#' @section Multiplex networks: +#' `node_x_ties()` returns one column per layer, plus a Diversity column, +#' rather than the distribution of tie values it returns otherwise. +#' Layers are taken by name, so a network multiplexed on any attribute is +#' covered, not only one multiplexed on `type`. +#' Every column stays the length of the whole nodeset, +#' so a node holding no tie in a layer scores 0 there rather than dropping out. #' @template param_data #' @template param_attr #' @template param_dir diff --git a/README.Rmd b/README.Rmd index 1a3aaff..ab10af8 100644 --- a/README.Rmd +++ b/README.Rmd @@ -16,7 +16,7 @@ library(netrics) # README lists the current API, so the wrappers in R/netrics-defunct.R are # dropped rather than advertised alongside their replacements. That file is # cleared at each minor release, so this list stays short. -defunct_fns <- c("node_by_coreness") +defunct_fns <- c("node_by_coreness", "net_x_mixed") netrics_fns <- setdiff(ls("package:netrics"), defunct_fns) list_functions <- function(string){ paste0("`", paste(paste0(netrics_fns[grepl(string, netrics_fns)], "()"), collapse = "`, `"), "`") @@ -53,7 +53,7 @@ For graph drawing, see [`{autograph}`](https://stocnet.github.io/autograph/), and for further testing and modelling capabilities see [`{migraph}`](https://stocnet.github.io/migraph/). -- [Marking](#marking) +- [Marks](#marks) - [Measures](#measures) - [Memberships](#memberships) - [Motifs](#motifs) @@ -64,11 +64,12 @@ see [`{migraph}`](https://stocnet.github.io/migraph/). - [Relationship to other packages](#relationship-to-other-packages) - [Funding details](#funding-details) -## Marking +## Marks `{netrics}` includes four special groups of functions, -each with their own pretty `print()` and `plot()` methods: +each with their own pretty `print()` method: marks, measures, motifs, and memberships. +(`plot()` methods for these results live in `{autograph}`.) Marks are logical scalars or vectors, measures are numeric, memberships categorical, and motifs result in tabular outputs. @@ -108,10 +109,11 @@ indicating e.g. that the first node is a member of group "A", the second in grou - `r list_functions("_in_")` -For example `node_in_brokering()` returns -the frequency of nodes' participation in -Gould-Fernandez brokerage roles for a one-mode network, -and the Jasny-Lubell brokerage roles for a two-mode network. +For example `node_in_brokering()` labels each node a powerhouse, +a connector, a linchpin, or a sideliner, +according to its brokerage activity and exclusivity (Hamilton et al. 2020). +For counts of the Gould-Fernandez brokerage roles instead, +see the motif `node_x_brokerage()`. These can be analysed alone, or used as a profile for establishing equivalence. `{netrics}` offers both HCA and CONCOR algorithms, @@ -139,7 +141,7 @@ For example, you might want to know about: - _Cohesion_: `r list_functions("density|reciprocity|transitivity|equivalency|congruency")` - _Hierarchy_: `r list_functions("hierarchy|connectedness|upper|efficiency|reciprocity")` - _Topology_: `r list_functions("core|factions|modularity|smallworld|balance|richclub")` -- _Resilience_: `r list_functions("cutpoint|bridge|hesion|articul")` +- _Resilience_: `r list_functions("cutpoint|bridge|hesion")` - _Brokerage_: `r list_functions("broke|hole|redundancy|constraint|effsize")` - _Diversity_: `r list_functions("diversity|phily|richness|assort")` - _Diffusion_: `r list_functions("adopt|infect|expos")` @@ -148,8 +150,13 @@ For example, you might want to know about: ### Stable -The easiest way to install the latest stable version of `{netrics}` is via CRAN. -Simply open the R console and enter: +The easiest way to get `{netrics}` is to install the whole `stocnet` family +from CRAN. Open the R console and enter: + +`install.packages('migraph')` + +This brings `{manynet}`, `{netrics}`, `{autograph}` and `{migraph}` together. +To install `{netrics}` alone, enter: `install.packages('netrics')` diff --git a/README.md b/README.md index dce5819..05df0b0 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ drawing, see [`{autograph}`](https://stocnet.github.io/autograph/), and for further testing and modelling capabilities see [`{migraph}`](https://stocnet.github.io/migraph/). -- [Marking](#marking) +- [Marks](#marks) - [Measures](#measures) - [Memberships](#memberships) - [Motifs](#motifs) @@ -49,12 +49,13 @@ for further testing and modelling capabilities see - [Relationship to other packages](#relationship-to-other-packages) - [Funding details](#funding-details) -## Marking +## Marks `{netrics}` includes four special groups of functions, each with their -own pretty `print()` and `plot()` methods: marks, measures, motifs, and -memberships. Marks are logical scalars or vectors, measures are numeric, -memberships categorical, and motifs result in tabular outputs. +own pretty `print()` method: marks, measures, motifs, and memberships. +(`plot()` methods for these results live in `{autograph}`.) Marks are +logical scalars or vectors, measures are numeric, memberships +categorical, and motifs result in tabular outputs. `{netrics}`’s `node_is_*()` and `tie_is_*()` functions offer fast logical tests of node- and tie-level properties. `node_is_*()` returns a @@ -152,9 +153,11 @@ member of group “A”, the second in group “B”, etc. `node_in_spinglass()`, `node_in_strong()`, `node_in_structural()`, `node_in_walktrap()`, `node_in_weak()` -For example `node_in_brokering()` returns the frequency of nodes’ -participation in Gould-Fernandez brokerage roles for a one-mode network, -and the Jasny-Lubell brokerage roles for a two-mode network. +For example `node_in_brokering()` labels each node a powerhouse, a +connector, a linchpin, or a sideliner, according to its brokerage +activity and exclusivity (Hamilton et al. 2020). For counts of the +Gould-Fernandez brokerage roles instead, see the motif +`node_x_brokerage()`. These can be analysed alone, or used as a profile for establishing equivalence. `{netrics}` offers both HCA and CONCOR algorithms, as well @@ -173,11 +176,11 @@ frequency in various motifs. These include: - `net_x_brokerage()`, `net_x_change()`, `net_x_correlation()`, `net_x_dyad()`, `net_x_hazard()`, `net_x_hierarchy()`, - `net_x_homophily()`, `net_x_mixed()`, `net_x_stability()`, - `net_x_tetrad()`, `net_x_triad()`, `node_x_alters()`, - `node_x_brokerage()`, `node_x_clique()`, `node_x_dyad()`, - `node_x_exposure()`, `node_x_path()`, `node_x_similarity()`, - `node_x_tetrad()`, `node_x_tie()`, `node_x_ties()`, `node_x_triad()` + `net_x_homophily()`, `net_x_stability()`, `net_x_tetrad()`, + `net_x_triad()`, `node_x_alters()`, `node_x_brokerage()`, + `node_x_clique()`, `node_x_dyad()`, `node_x_exposure()`, + `node_x_path()`, `node_x_similarity()`, `node_x_tetrad()`, + `node_x_tie()`, `node_x_ties()`, `node_x_triad()` ## Analysis @@ -229,8 +232,13 @@ about: ### Stable -The easiest way to install the latest stable version of `{netrics}` is -via CRAN. Simply open the R console and enter: +The easiest way to get `{netrics}` is to install the whole `stocnet` +family from CRAN. Open the R console and enter: + +`install.packages('migraph')` + +This brings `{manynet}`, `{netrics}`, `{autograph}` and `{migraph}` +together. To install `{netrics}` alone, enter: `install.packages('netrics')` diff --git a/man-roxygen/param_times.R b/man-roxygen/param_times.R new file mode 100644 index 0000000..60a80cf --- /dev/null +++ b/man-roxygen/param_times.R @@ -0,0 +1,6 @@ +#' @param times Integer scalar, how many times the algorithm repeats its work. +#' Where the algorithm is stochastic, this is how many times it runs, +#' and the best or the most frequent result is kept. +#' Where the algorithm searches, this is how many steps the search takes. +#' More repetitions give a more reliable result and take longer, +#' so each function documents its own default. diff --git a/man/mark_degree.Rd b/man/mark_degree.Rd index 6f0f13c..9fd2f1f 100644 --- a/man/mark_degree.Rd +++ b/man/mark_degree.Rd @@ -53,8 +53,8 @@ node_is_universal(create_star(11)) \seealso{ Other degree: \code{\link{measure_central_degree}}, -\code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralities_degree}} +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_centralisation_degree}} Other marks: \code{\link{mark_core}}, diff --git a/man/mark_dyads.Rd b/man/mark_dyads.Rd index b997641..84df6df 100644 --- a/man/mark_dyads.Rd +++ b/man/mark_dyads.Rd @@ -51,10 +51,10 @@ Other tie: \code{\link{mark_ties}}, \code{\link{mark_triangles}}, \code{\link{measure_broker_tie}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}} } \concept{marks} \concept{tie} diff --git a/man/mark_select_tie.Rd b/man/mark_select_tie.Rd index ae486b2..f645883 100644 --- a/man/mark_select_tie.Rd +++ b/man/mark_select_tie.Rd @@ -57,10 +57,10 @@ Other tie: \code{\link{mark_ties}}, \code{\link{mark_triangles}}, \code{\link{measure_broker_tie}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}} Other selection: \code{\link{mark_select_node}} diff --git a/man/mark_ties.Rd b/man/mark_ties.Rd index 974f3c2..7d4967e 100644 --- a/man/mark_ties.Rd +++ b/man/mark_ties.Rd @@ -69,10 +69,10 @@ Other tie: \code{\link{mark_select_tie}}, \code{\link{mark_triangles}}, \code{\link{measure_broker_tie}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}} } \concept{marks} \concept{tie} diff --git a/man/mark_triangles.Rd b/man/mark_triangles.Rd index 5ec173b..bb8e018 100644 --- a/man/mark_triangles.Rd +++ b/man/mark_triangles.Rd @@ -77,10 +77,10 @@ Other tie: \code{\link{mark_select_tie}}, \code{\link{mark_ties}}, \code{\link{measure_broker_tie}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}} Other cohesion: \code{\link{measure_breadth}}, diff --git a/man/measure_assort_net.Rd b/man/measure_assort_net.Rd index 54593a9..f5e3a77 100644 --- a/man/measure_assort_net.Rd +++ b/man/measure_assort_net.Rd @@ -172,10 +172,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_assort_node.Rd b/man/measure_assort_node.Rd index e6d044f..90ab86d 100644 --- a/man/measure_assort_node.Rd +++ b/man/measure_assort_node.Rd @@ -101,10 +101,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_breadth.Rd b/man/measure_breadth.Rd index a73cdf2..a0f9a9b 100644 --- a/man/measure_breadth.Rd +++ b/man/measure_breadth.Rd @@ -67,10 +67,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_broker_node.Rd b/man/measure_broker_node.Rd index c283d7b..a2f49b2 100644 --- a/man/measure_broker_node.Rd +++ b/man/measure_broker_node.Rd @@ -143,10 +143,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_broker_tie.Rd b/man/measure_broker_tie.Rd index 7b77509..3afd19d 100644 --- a/man/measure_broker_tie.Rd +++ b/man/measure_broker_tie.Rd @@ -33,6 +33,9 @@ where high values indicate ties' embeddedness in dense local environments. A tie whose two endpoints have no other neighbours has nothing to be embedded in, and so returns \code{NaN} rather than 0. } +\examples{ +tie_by_cohesion(ison_adolescents) +} \seealso{ Other brokerage: \code{\link{measure_broker_node}}, @@ -51,10 +54,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, @@ -75,10 +78,10 @@ Other tie: \code{\link{mark_select_tie}}, \code{\link{mark_ties}}, \code{\link{mark_triangles}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}} } \concept{brokerage} \concept{measures} diff --git a/man/measure_brokerage.Rd b/man/measure_brokerage.Rd index 7b0d1e5..b55fb8b 100644 --- a/man/measure_brokerage.Rd +++ b/man/measure_brokerage.Rd @@ -67,10 +67,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_central_between.Rd b/man/measure_central_between.Rd index 4056f62..19b4ee5 100644 --- a/man/measure_central_between.Rd +++ b/man/measure_central_between.Rd @@ -180,21 +180,21 @@ Shimbel, A. 1953. } \seealso{ Other betweenness: -\code{\link{measure_centralisation_between}}, -\code{\link{measure_centralities_between}} +\code{\link{measure_central_tie_between}}, +\code{\link{measure_centralisation_between}} Other centrality: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_centralisation_between}}, \code{\link{measure_centralisation_close}}, \code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralisation_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_centralisation_eigen}} Other measures: \code{\link{measure_assort_net}}, @@ -206,10 +206,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_central_close.Rd b/man/measure_central_close.Rd index ee6133a..f52571c 100644 --- a/man/measure_central_close.Rd +++ b/man/measure_central_close.Rd @@ -413,21 +413,21 @@ Noh, J.D. and R. Rieger. 2004. } \seealso{ Other closeness: -\code{\link{measure_centralisation_close}}, -\code{\link{measure_centralities_close}} +\code{\link{measure_central_tie_close}}, +\code{\link{measure_centralisation_close}} Other centrality: \code{\link{measure_central_between}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_centralisation_between}}, \code{\link{measure_centralisation_close}}, \code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralisation_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_centralisation_eigen}} Other measures: \code{\link{measure_assort_net}}, @@ -439,10 +439,10 @@ Other measures: \code{\link{measure_central_between}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_central_degree.Rd b/man/measure_central_degree.Rd index a1f30eb..a3e9f32 100644 --- a/man/measure_central_degree.Rd +++ b/man/measure_central_degree.Rd @@ -101,6 +101,16 @@ with those of another. dispatch: a multiplex network does not itself say \emph{which} two types of tie to contrast, so \code{tie1} and \code{tie2} must be named. } +\section{Multiplex networks}{ + +\code{node_by_degree()} counts every tie a node holds, whatever its layer, +so a node tied twice to the same alter on two layers scores 2. +To score one layer at a time, take it first with +\code{\link[manynet:to_uniplex]{manynet::to_uniplex()}}, or use \code{node_by_multidegree()} to contrast two. +Note that \code{to_uniplex()} drops the nodes that hold none of the retained +ties, so scores from two layers are of different lengths. +} + \section{Degree centrality}{ The degree of a node is the number of connections it has. @@ -182,21 +192,21 @@ Joyce, Karen E., Paul J. Laurienti, Jonathan H. Burdette, and Satoru Hayasaka. 2 \seealso{ Other degree: \code{\link{mark_degree}}, -\code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralities_degree}} +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_centralisation_degree}} Other centrality: \code{\link{measure_central_between}}, \code{\link{measure_central_close}}, \code{\link{measure_central_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_centralisation_between}}, \code{\link{measure_centralisation_close}}, \code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralisation_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_centralisation_eigen}} Other measures: \code{\link{measure_assort_net}}, @@ -208,10 +218,10 @@ Other measures: \code{\link{measure_central_between}}, \code{\link{measure_central_close}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_central_eigen.Rd b/man/measure_central_eigen.Rd index fb50c38..cca89f4 100644 --- a/man/measure_central_eigen.Rd +++ b/man/measure_central_eigen.Rd @@ -328,21 +328,21 @@ Everett, Martin G., and Stephen P. Borgatti. 2014. } \seealso{ Other eigenvector: -\code{\link{measure_centralisation_eigen}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_central_tie_eigen}}, +\code{\link{measure_centralisation_eigen}} Other centrality: \code{\link{measure_central_between}}, \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_centralisation_between}}, \code{\link{measure_centralisation_close}}, \code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralisation_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_centralisation_eigen}} Other measures: \code{\link{measure_assort_net}}, @@ -354,10 +354,10 @@ Other measures: \code{\link{measure_central_between}}, \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_centralities_between.Rd b/man/measure_central_tie_between.Rd similarity index 89% rename from man/measure_centralities_between.Rd rename to man/measure_central_tie_between.Rd index 9a6783f..86babc7 100644 --- a/man/measure_centralities_between.Rd +++ b/man/measure_central_tie_between.Rd @@ -1,7 +1,7 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/measure_centrality_between.R -\name{measure_centralities_between} -\alias{measure_centralities_between} +\name{measure_central_tie_between} +\alias{measure_central_tie_between} \alias{tie_by_betweenness} \title{Measuring ties betweenness-like centrality} \usage{ @@ -77,13 +77,13 @@ Other centrality: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_centralisation_between}}, \code{\link{measure_centralisation_close}}, \code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralisation_eigen}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_centralisation_eigen}} Other measures: \code{\link{measure_assort_net}}, @@ -96,9 +96,9 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, @@ -120,9 +120,9 @@ Other tie: \code{\link{mark_ties}}, \code{\link{mark_triangles}}, \code{\link{measure_broker_tie}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}} } \concept{betweenness} \concept{centrality} diff --git a/man/measure_centralities_close.Rd b/man/measure_central_tie_close.Rd similarity index 87% rename from man/measure_centralities_close.Rd rename to man/measure_central_tie_close.Rd index 77cbcc8..67f79b3 100644 --- a/man/measure_centralities_close.Rd +++ b/man/measure_central_tie_close.Rd @@ -1,7 +1,7 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/measure_centrality_closeness.R -\name{measure_centralities_close} -\alias{measure_centralities_close} +\name{measure_central_tie_close} +\alias{measure_central_tie_close} \alias{tie_by_closeness} \title{Measuring ties closeness-like centrality} \usage{ @@ -55,13 +55,13 @@ Other centrality: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_centralisation_between}}, \code{\link{measure_centralisation_close}}, \code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralisation_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_centralisation_eigen}} Other measures: \code{\link{measure_assort_net}}, @@ -74,9 +74,9 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, @@ -98,9 +98,9 @@ Other tie: \code{\link{mark_ties}}, \code{\link{mark_triangles}}, \code{\link{measure_broker_tie}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}} } \concept{centrality} \concept{closeness} diff --git a/man/measure_centralities_degree.Rd b/man/measure_central_tie_degree.Rd similarity index 87% rename from man/measure_centralities_degree.Rd rename to man/measure_central_tie_degree.Rd index b783201..1265330 100644 --- a/man/measure_centralities_degree.Rd +++ b/man/measure_central_tie_degree.Rd @@ -1,7 +1,7 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/measure_centrality_degree.R -\name{measure_centralities_degree} -\alias{measure_centralities_degree} +\name{measure_central_tie_degree} +\alias{measure_central_tie_degree} \alias{tie_by_degree} \title{Measuring ties degree-like centrality} \usage{ @@ -54,13 +54,13 @@ Other centrality: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_centralisation_between}}, \code{\link{measure_centralisation_close}}, \code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralisation_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_centralisation_eigen}} Other measures: \code{\link{measure_assort_net}}, @@ -73,9 +73,9 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, @@ -97,9 +97,9 @@ Other tie: \code{\link{mark_ties}}, \code{\link{mark_triangles}}, \code{\link{measure_broker_tie}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_eigen}} } \concept{centrality} \concept{degree} diff --git a/man/measure_centralities_eigen.Rd b/man/measure_central_tie_eigen.Rd similarity index 87% rename from man/measure_centralities_eigen.Rd rename to man/measure_central_tie_eigen.Rd index 689f0fa..dc47522 100644 --- a/man/measure_centralities_eigen.Rd +++ b/man/measure_central_tie_eigen.Rd @@ -1,7 +1,7 @@ % Generated by roxygen2: do not edit by hand % Please edit documentation in R/measure_centrality_eigen.R -\name{measure_centralities_eigen} -\alias{measure_centralities_eigen} +\name{measure_central_tie_eigen} +\alias{measure_central_tie_eigen} \alias{tie_by_eigenvector} \title{Measuring ties eigenvector-like centrality} \usage{ @@ -54,13 +54,13 @@ Other centrality: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, \code{\link{measure_centralisation_between}}, \code{\link{measure_centralisation_close}}, \code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralisation_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}} +\code{\link{measure_centralisation_eigen}} Other measures: \code{\link{measure_assort_net}}, @@ -73,9 +73,9 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, @@ -97,9 +97,9 @@ Other tie: \code{\link{mark_ties}}, \code{\link{mark_triangles}}, \code{\link{measure_broker_tie}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}} +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}} } \concept{centrality} \concept{eigenvector} diff --git a/man/measure_centralisation_between.Rd b/man/measure_centralisation_between.Rd index 80f1ba2..54a90cc 100644 --- a/man/measure_centralisation_between.Rd +++ b/man/measure_centralisation_between.Rd @@ -74,20 +74,20 @@ Borgatti, Stephen P., and Martin G. Everett. 1997. \seealso{ Other betweenness: \code{\link{measure_central_between}}, -\code{\link{measure_centralities_between}} +\code{\link{measure_central_tie_between}} Other centrality: \code{\link{measure_central_between}}, \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_centralisation_close}}, \code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralisation_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_centralisation_eigen}} } \concept{betweenness} \concept{centrality} diff --git a/man/measure_centralisation_close.Rd b/man/measure_centralisation_close.Rd index c464fba..fe0a5da 100644 --- a/man/measure_centralisation_close.Rd +++ b/man/measure_centralisation_close.Rd @@ -107,20 +107,20 @@ Borgatti, Stephen P., and Martin G. Everett. 1997. \seealso{ Other closeness: \code{\link{measure_central_close}}, -\code{\link{measure_centralities_close}} +\code{\link{measure_central_tie_close}} Other centrality: \code{\link{measure_central_between}}, \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_centralisation_between}}, \code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralisation_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_centralisation_eigen}} } \concept{centrality} \concept{closeness} diff --git a/man/measure_centralisation_degree.Rd b/man/measure_centralisation_degree.Rd index 69fe18b..8a3779f 100644 --- a/man/measure_centralisation_degree.Rd +++ b/man/measure_centralisation_degree.Rd @@ -101,20 +101,20 @@ Borgatti, Stephen P., and Martin G. Everett. 1997. Other degree: \code{\link{mark_degree}}, \code{\link{measure_central_degree}}, -\code{\link{measure_centralities_degree}} +\code{\link{measure_central_tie_degree}} Other centrality: \code{\link{measure_central_between}}, \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_centralisation_between}}, \code{\link{measure_centralisation_close}}, -\code{\link{measure_centralisation_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_centralisation_eigen}} } \concept{centrality} \concept{degree} diff --git a/man/measure_centralisation_eigen.Rd b/man/measure_centralisation_eigen.Rd index 77c5de8..59ea006 100644 --- a/man/measure_centralisation_eigen.Rd +++ b/man/measure_centralisation_eigen.Rd @@ -59,20 +59,20 @@ Borgatti, Stephen P., and Martin G. Everett. 1997. \seealso{ Other eigenvector: \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_central_tie_eigen}} Other centrality: \code{\link{measure_central_between}}, \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_centralisation_between}}, \code{\link{measure_centralisation_close}}, -\code{\link{measure_centralisation_degree}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}} +\code{\link{measure_centralisation_degree}} } \concept{centrality} \concept{eigenvector} diff --git a/man/measure_closure.Rd b/man/measure_closure.Rd index f293d5d..54547f6 100644 --- a/man/measure_closure.Rd +++ b/man/measure_closure.Rd @@ -132,10 +132,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, \code{\link{measure_core}}, diff --git a/man/measure_closure_node.Rd b/man/measure_closure_node.Rd index a4aa46e..6ca0952 100644 --- a/man/measure_closure_node.Rd +++ b/man/measure_closure_node.Rd @@ -93,10 +93,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_cohesion}}, \code{\link{measure_core}}, diff --git a/man/measure_cohesion.Rd b/man/measure_cohesion.Rd index b081a89..c99f68b 100644 --- a/man/measure_cohesion.Rd +++ b/man/measure_cohesion.Rd @@ -138,10 +138,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_core}}, diff --git a/man/measure_core.Rd b/man/measure_core.Rd index cd38c6c..ae65e9a 100644 --- a/man/measure_core.Rd +++ b/man/measure_core.Rd @@ -113,10 +113,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_diffusion_infection.Rd b/man/measure_diffusion_infection.Rd index 01a83f5..1779ff1 100644 --- a/man/measure_diffusion_infection.Rd +++ b/man/measure_diffusion_infection.Rd @@ -78,10 +78,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_diffusion_net.Rd b/man/measure_diffusion_net.Rd index 2cc2331..edfb6bd 100644 --- a/man/measure_diffusion_net.Rd +++ b/man/measure_diffusion_net.Rd @@ -191,10 +191,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_diffusion_node.Rd b/man/measure_diffusion_node.Rd index fb0aa89..1a2399e 100644 --- a/man/measure_diffusion_node.Rd +++ b/man/measure_diffusion_node.Rd @@ -147,10 +147,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_diverse_net.Rd b/man/measure_diverse_net.Rd index f7987bc..aa7cfa3 100644 --- a/man/measure_diverse_net.Rd +++ b/man/measure_diverse_net.Rd @@ -162,10 +162,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_diverse_node.Rd b/man/measure_diverse_node.Rd index 6fc229a..9202801 100644 --- a/man/measure_diverse_node.Rd +++ b/man/measure_diverse_node.Rd @@ -77,10 +77,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_features.Rd b/man/measure_features.Rd index d4a8e6a..74f2814 100644 --- a/man/measure_features.Rd +++ b/man/measure_features.Rd @@ -204,10 +204,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_fit.Rd b/man/measure_fit.Rd index 5949387..52a0698 100644 --- a/man/measure_fit.Rd +++ b/man/measure_fit.Rd @@ -270,10 +270,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_fragmentation.Rd b/man/measure_fragmentation.Rd index fea5ade..95ffe22 100644 --- a/man/measure_fragmentation.Rd +++ b/man/measure_fragmentation.Rd @@ -81,10 +81,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_hierarchy.Rd b/man/measure_hierarchy.Rd index fe44790..7dd8b52 100644 --- a/man/measure_hierarchy.Rd +++ b/man/measure_hierarchy.Rd @@ -84,10 +84,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/measure_periods.Rd b/man/measure_periods.Rd index 1d6d4ad..ce7ef83 100644 --- a/man/measure_periods.Rd +++ b/man/measure_periods.Rd @@ -25,6 +25,9 @@ All can be retrieved with \code{attr()}. \description{ \code{net_by_waves()} measures the number of waves in longitudinal network data. } +\examples{ +net_by_waves(ison_classmates) +} \seealso{ Other change: \code{\link{motif_periods}} @@ -40,10 +43,10 @@ Other measures: \code{\link{measure_central_close}}, \code{\link{measure_central_degree}}, \code{\link{measure_central_eigen}}, -\code{\link{measure_centralities_between}}, -\code{\link{measure_centralities_close}}, -\code{\link{measure_centralities_degree}}, -\code{\link{measure_centralities_eigen}}, +\code{\link{measure_central_tie_between}}, +\code{\link{measure_central_tie_close}}, +\code{\link{measure_central_tie_degree}}, +\code{\link{measure_central_tie_eigen}}, \code{\link{measure_closure}}, \code{\link{measure_closure_node}}, \code{\link{measure_cohesion}}, diff --git a/man/member_brokerage.Rd b/man/member_brokerage.Rd index a7dde2d..21a6955 100644 --- a/man/member_brokerage.Rd +++ b/man/member_brokerage.Rd @@ -26,9 +26,12 @@ If the network is labelled, then the assignments will be labelled with the nodes' names. } \description{ -\code{node_in_brokerage()} returns nodes membership as a powerhouse, +\code{node_in_brokering()} returns nodes membership as a powerhouse, connector, linchpin, or sideliner according to Hamilton et al. (2020). } +\examples{ +node_in_brokering(ison_networkers, "Discipline") +} \references{ \subsection{On brokerage activity and exclusivity}{ diff --git a/man/member_cliques.Rd b/man/member_cliques.Rd index cc15a68..01fc59b 100644 --- a/man/member_cliques.Rd +++ b/man/member_cliques.Rd @@ -18,13 +18,12 @@ For more information on possible coercions, see e.g. \code{\link[manynet:as_stoc Note that if the number of nodes is not divisible into groups of equal size, there may be some larger or smaller groups.} -\item{times}{An integer of the number of search iterations the algorithm should complete. -By default this is the number of nodes in the network multiplied by the number of groups. -This heuristic may be insufficient for small networks and numbers of groups, -and burdensome for large networks and numbers of groups, but can be overwritten. -At every 10th iteration, a stronger perturbation of a number of successive changes, -approximately the number of nodes divided by the number of groups, -will take place irrespective of whether it improves the objective function.} +\item{times}{Integer scalar, how many times the algorithm repeats its work. +Where the algorithm is stochastic, this is how many times it runs, +and the best or the most frequent result is kept. +Where the algorithm searches, this is how many steps the search takes. +More repetitions give a more reliable result and take longer, +so each function documents its own default.} } \value{ A \code{node_member} character vector the length of the nodes in the network, @@ -39,6 +38,14 @@ cliques: \item \code{node_in_roulette()} assigns nodes to maximally diverse groups. } } +\details{ +\code{times} defaults to the number of nodes multiplied by the number of groups. +This heuristic may be insufficient for small networks and numbers of groups, +and burdensome for large ones, but can be overwritten. +At every 10th iteration, a stronger perturbation of a number of successive +changes, approximately the number of nodes divided by the number of groups, +takes place whether or not it improves the objective function. +} \section{Maximally diverse grouping problem}{ This well known computational problem is a NP-hard problem @@ -64,6 +71,9 @@ to ensure that a robust solution from the broader state space is identified. The user is referred to Lai and Hao (2016) and Lai et al (2021) for more details. } +\examples{ +node_in_roulette(ison_adolescents, num_groups = 3) +} \references{ \subsection{On the maximally diverse grouping problem}{ diff --git a/man/member_community.Rd b/man/member_community.Rd index 18efb52..4c42b94 100644 --- a/man/member_community.Rd +++ b/man/member_community.Rd @@ -39,9 +39,12 @@ This argument is ignored on a network small enough for \code{node_in_optimal()}, which already returns the maximum modularity partition.} -\item{times}{An integer of how many times each stochastic algorithm is run -when \code{consensus = TRUE}. By default 20. Deterministic algorithms are run -once however this is set.} +\item{times}{Integer scalar, how many times the algorithm repeats its work. +Where the algorithm is stochastic, this is how many times it runs, +and the best or the most frequent result is kept. +Where the algorithm searches, this is how many steps the search takes. +More repetitions give a more reliable result and take longer, +so each function documents its own default.} } \value{ A \code{node_member} character vector the length of the nodes in the network, @@ -68,6 +71,10 @@ is placed together until they agree. This costs considerably more time than selection, but does not rest the answer on a single run of a single algorithm. } +\details{ +\code{times} applies only when \code{consensus = TRUE}, and is 20 by default. +Deterministic algorithms are run once however it is set. +} \examples{ node_in_community(ison_adolescents) } diff --git a/man/member_community_non.Rd b/man/member_community_non.Rd index 13d960d..0fc8255 100644 --- a/man/member_community_non.Rd +++ b/man/member_community_non.Rd @@ -53,8 +53,12 @@ Note that for \code{node_in_louvain()} and \code{node_in_leiden()} each candidat requires its own search over the resolution parameter, so a large \code{Kmax} is costly on large networks.} -\item{times}{Integer indicating number of simulations/walks used. -By default, \code{times=50}.} +\item{times}{Integer scalar, how many times the algorithm repeats its work. +Where the algorithm is stochastic, this is how many times it runs, +and the best or the most frequent result is kept. +Where the algorithm searches, this is how many steps the search takes. +More repetitions give a more reliable result and take longer, +so each function documents its own default.} \item{max_k}{Integer constant, the number of spins to use as an upper limit of communities to be found. Some sets can be empty at the end.} diff --git a/man/motif_composition.Rd b/man/motif_composition.Rd index e0c4245..6b33ffe 100644 --- a/man/motif_composition.Rd +++ b/man/motif_composition.Rd @@ -52,6 +52,16 @@ Each branches internally on the type of network or attribute given, so the same function serves weighted, multiplex, and two-mode networks, and categorical as well as continuous attributes. } +\section{Multiplex networks}{ + +\code{node_x_ties()} returns one column per layer, plus a Diversity column, +rather than the distribution of tie values it returns otherwise. +Layers are taken by name, so a network multiplexed on any attribute is +covered, not only one multiplexed on \code{type}. +Every column stays the length of the whole nodeset, +so a node holding no tie in a layer scores 0 there rather than dropping out. +} + \section{Tie composition}{ For a weighted network this returns the distribution of each node's tie diff --git a/man/motif_net.Rd b/man/motif_net.Rd index 12897b2..733b639 100644 --- a/man/motif_net.Rd +++ b/man/motif_net.Rd @@ -42,6 +42,14 @@ in a network: See also \href{https://www.graphclasses.org/smallgraphs.html}{graph classes}. } +\section{Multiplex networks}{ + +\code{net_x_triad()} takes the mixed census on a multiplex network, +splitting it into layers by mode rather than by position. +To census one layer alone, take it first with \code{\link[manynet:to_uniplex]{manynet::to_uniplex()}}. +\code{net_x_dyad()} and \code{net_x_tetrad()} count every tie whatever its layer. +} + \section{Dyad census}{ The dyad census counts the number of mutual, asymmetric, and null dyads diff --git a/man/motif_path.Rd b/man/motif_path.Rd index 88928b5..179dbb5 100644 --- a/man/motif_path.Rd +++ b/man/motif_path.Rd @@ -34,6 +34,14 @@ For multiplex networks, the various types of ties are bound together. of each node to every other node in the network. } } +\section{Multiplex networks}{ + +\code{node_x_tie()} binds the layers together, giving one block of columns +per layer, whatever attribute the network is multiplexed on. +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()}}. +} + \examples{ task_eg <- to_named(to_uniplex(ison_algebra, "tasks")) (tie_cen <- node_x_tie(task_eg)) diff --git a/man/motif_periods.Rd b/man/motif_periods.Rd index 21e9523..56640a1 100644 --- a/man/motif_periods.Rd +++ b/man/motif_periods.Rd @@ -37,6 +37,9 @@ These functions measure certain topological features of networks: These \verb{net_*()} functions return a numeric vector the length of the number of networks minus one. E.g., the periods between waves. } +\examples{ +net_x_change(ison_monks) +} \seealso{ Other change: \code{\link{measure_periods}} diff --git a/pkgdown/_pkgdown.yml b/pkgdown/_pkgdown.yml index 0bf707f..c03d285 100644 --- a/pkgdown/_pkgdown.yml +++ b/pkgdown/_pkgdown.yml @@ -70,7 +70,7 @@ articles: - "`articles/position`" - "`articles/topology`" reference: - - title: "Marking" + - title: "Marks" desc: | Functions for identifying properties of nodes or ties, all returning logical scalars or vectors. @@ -87,7 +87,7 @@ reference: contents: - starts_with("tie_is_") - - title: "Measuring" + - title: "Measures" desc: | Functions for measuring networks and returning a numeric vector or value. `net_` measures return one or, in some cases of two-mode measures, @@ -125,18 +125,19 @@ reference: - title: "Memberships" desc: | - Motifs are functions for calculating network subgraphs, - always return a matrix or table of nodes as rows and motif or other property as columns, - and can be recognised by the `_by_` in the function name. - Memberships are functions for identifying community, cluster, or class memberships, - always return a string vector the length of the nodes in the network, - and can be recognised by the `_in_` in the function name. - - subtitle: "Motifs" - contents: - - contains("_x_") - - subtitle: "Members" + Functions for identifying nodes' community, cluster, or class membership, + recognisable by the `_in_` in the function name. + They return a character vector the length of the nodes in the network. contents: - contains("_in_") + + - title: "Motifs" + desc: | + Functions for tabulating the subgraphs that nodes or networks participate in, + recognisable by the `_x_` in the function name. + They return a matrix or table, with the motif or other property as columns. + contents: + - contains("_x_") - title: "Methods" desc: "Methods used in other functions but documented here:" From 1cd843e909fc04497a79c30d022802425dfb925d Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 14:30:52 +0200 Subject: [PATCH 63/68] Added roxygen templates to standardise argument vocabulary --- .github/CONTRIBUTING.md | 19 ++++- NEWS.md | 16 ++++- R/class_metrics.R | 42 +++++++++++ R/measure_centrality_eigen.R | 24 ++++--- R/measure_closure.R | 20 ++++-- R/measure_features.R | 67 +++++++++-------- R/member_cliques.R | 24 ++++--- R/member_community.R | 71 +++++++++++-------- R/member_core.R | 38 +++++----- R/member_equivalence.R | 33 +++++---- R/method_k.R | 32 ++++----- R/motif_brokerage.R | 8 +-- man-roxygen/param_k.R | 6 +- man-roxygen/param_standardized.R | 7 ++ man-roxygen/param_variant.R | 3 + man/measure_central_eigen.Rd | 12 +++- man/measure_closure.Rd | 14 +++- man/measure_features.Rd | 68 +++++++++++------- man/measure_fit.Rd | 35 +++++---- man/member_cliques.Rd | 9 ++- man/member_community.Rd | 16 ++++- man/member_community_hier.Rd | 15 ++-- man/member_community_non.Rd | 23 +++--- man/member_equivalence.Rd | 22 ++++-- man/method_kselect.Rd | 8 +-- man/motif_brokerage_net.Rd | 10 ++- man/motif_brokerage_node.Rd | 10 ++- tests/testthat/helper-contract.R | 17 ++--- .../test-measure_centrality_contract.R | 10 +-- .../testthat/test-measure_closure_contract.R | 8 +-- .../testthat/test-measure_features_contract.R | 12 ++-- tests/testthat/test-measure_fit.R | 4 +- tests/testthat/test-member_cliques.R | 2 +- tests/testthat/test-member_nodes.R | 2 +- 34 files changed, 454 insertions(+), 253 deletions(-) create mode 100644 man-roxygen/param_standardized.R create mode 100644 man-roxygen/param_variant.R diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 3a0c5df..71aaf49 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -185,12 +185,29 @@ Before adding an argument, look for the name the package already uses for that i |---|---| | `normalized` | divide by a theoretical maximum, so scores compare across networks | | `scaled` | divide by the observed maximum, so the highest-scoring node takes 1 | +| `standardized` | a z-score against a null model, so it can be negative and has no fixed range | | `decay` | any per-step discount, always a proportion on `[0,1]` where higher values discount less | | `alpha` | only Opsahl et al.'s trade-off between degree and strength in `node_by_degree()` | | `direction` | `"all"`, `"in"` or `"out"`, validated with `match.arg()` | | `cutoff` | a geodesic distance bound | | `k` | a target number of groups, or the name of a `k_*` selection method | -| `cluster`, `coreness`, `regularity` | select a method helper, as above | +| `groups` | a fixed number of groups, where no `k_*` method can apply | +| `max_k` | the upper bound on the number of groups evaluated | +| `times` | how many times an algorithm repeats its work | +| `variant` | which definition of the same quantity to compute, reported back as the result's `variant` | +| `walks` | which closed walks to count: `"all"`, `"odd"` or `"even"` | +| `attribute` | the node or tie attribute a measure reads | +| `resolution` | the Reichardt-Bornholdt gamma of a modularity-based algorithm | +| `steps` | the length of one random walk | +| `select`, `ranks` | how many nodes or ties a mark selects | +| `cluster`, `coreness`, `regularity`, `split` | select a method helper, as above | + +`k` and `groups` are not the same argument, and the difference is worth keeping. +Every `k_*` helper takes an `hclust` object as its first argument, so only a +function that builds a dendrogram can be handed a selection method by name. +`node_in_core()` splits one continuous score and `node_in_roulette()` searches +group assignments directly, so both take `groups` rather than `k`. +Naming them `k` would advertise a `k = "silhouette"` that cannot work. Four points follow from this: diff --git a/NEWS.md b/NEWS.md index 6e465ae..57fc4bf 100644 --- a/NEWS.md +++ b/NEWS.md @@ -4,10 +4,16 @@ - Removed CRAN version check from `.onAttach()` making `library(netrics)` faster to attach - Fixed release workflow doubling `actions/actions/checkout` path segment -- Added `param_cutoff` roxygen template, correctly documenting geodesic cutoff for six functions -- Added `param_decay` roxygen template, correctly documenting decay parameter - Updated GitHub Actions workflows to latest major action versions - Updated CONTRIBUTING to be clearer about documentation, website and NEWS conventions +- Added roxygen templates to standardise argument vocabulary + - `param_cutoff` + - `param_decay` + - `param_times` + - `param_variant` + - `param_standardized` +- Updated the website function overview to use the `NEWS.md` family headings +- Updated the README to recommend installing the whole family via `{migraph}` ## Measures @@ -60,7 +66,7 @@ - Moved `node_by_posneg()` to the eigenvector doc group - Improved `node_by_subgraph()` - Now honours tie weights - - Added `method=` to choose which closed walks to count: `"odd"`, `"even"`, or`"all"` (default, both) + - Added `walks=` to choose which closed walks to count: `"odd"`, `"even"` or `"all"` - Updated references in centrality documentation - Corrected `node_by_eigenvector()` to cite Bonacich (1972), not only (1991) - Added Freeman (1978) to `node_by_degree()` and the centralisation functions @@ -118,11 +124,15 @@ - `"Sender"` for core out-ties and periphery in-ties - `"Receiver"` for core in-ties and periphery out-ties - Fixed sorting numbered middle labels alphabetically or from arbitrary cluster numbers +- Improved `node_in_equivalence()` to announce the `cluster_*()` and `k_*()` used - Added `node_in_block()` for direct blockmodelling for partitions that minimise `net_by_inconsistency()` - Fixed `node_in_regular()` to compute regular equivalence correctly - Choose between `regularity = "rolesim"` (default) and `"rege"` for recursive similarity - Note existing scripts calling `node_in_regular()` will now return more correct results - Moved former similarity of local embedding to `node_in_motif()` +- Renamed `Kmax=` to `max_k=` in the community and equivalence functions +- Renamed `num_groups=` to `groups=` in `node_in_roulette()` +- Renamed `cluster_by=` to `split=` in `node_in_core()` ## Motifs diff --git a/R/class_metrics.R b/R/class_metrics.R index 33358ca..08af34f 100644 --- a/R/class_metrics.R +++ b/R/class_metrics.R @@ -71,6 +71,48 @@ resolve_scaled <- function(scaled, scale = NULL) { scaled } +# `Kmax` was the original spelling of the upper bound on the number of groups, +# but it was the one camel-case argument in an otherwise lowercase API, and +# `node_in_spinglass()` already spelled the same idea `max_k`. The `max_*` +# prefix also leaves room for other bounds later. Accepts the old spelling +# and warns, as `resolve_scaled()` does. +resolve_max_k <- function(max_k, Kmax = NULL) { + if(!is.null(Kmax)) { + warning("The `Kmax` argument has been renamed `max_k`, ", + "for consistency with the rest of the package. ", + "Please use `max_k` instead.", call. = FALSE) + max_k <- Kmax + } + max_k +} + +# `num_groups` was the one place a fixed number of groups was not called +# `groups`, as `node_in_core()` calls it. Accepts the old spelling and warns. +resolve_groups <- function(groups, num_groups = NULL) { + if(!is.null(num_groups)) { + warning("The `num_groups` argument has been renamed `groups`, ", + "the name this package uses for a fixed number of groups. ", + "Please use `groups` instead.", call. = FALSE) + groups <- num_groups + } + groups +} + +# `method` named four unrelated things: which definition of reciprocity, of +# small-worldness and of core-periphery fit to compute, and which closed walks +# to count. One word cannot carry all four, so the first three became +# `variant`, the choice of definition the result then reports, and the fourth +# became `walks`. Accepts the old spelling and warns. +resolve_method <- function(new, method = NULL, new_name) { + if(!is.null(method)) { + warning("The `method` argument has been renamed `", new_name, "`, ", + "since `method` named several different things in this package. ", + "Please use `", new_name, "` instead.", call. = FALSE) + new <- method + } + new +} + # Several measures discount a contribution once per step of distance or walk # length. The literature names that discount differently in each case — # Bonacich and Lloyd's alpha, RoleSim's beta, PageRank's damping factor, diff --git a/R/measure_centrality_eigen.R b/R/measure_centrality_eigen.R index 587b9fe..57e9acb 100644 --- a/R/measure_centrality_eigen.R +++ b/R/measure_centrality_eigen.R @@ -307,13 +307,15 @@ node_by_hub <- function(.data, scaled = TRUE){ #' @rdname measure_central_eigen #' @template param_decay -#' @param method Character string indicating which closed walks to count. +#' @param walks Character string indicating which closed walks to count. #' By default `"all"`, which is subgraph centrality as usually defined. #' `"odd"` counts only walks of odd length and `"even"` only those of even #' length; the two sum to `"all"`. #' Odd closed walks cannot occur within a bipartite structure, so a node #' scoring near zero on `"odd"` sits in a locally two-mode-like neighbourhood. #' See [net_by_bipartivity()] for the network-level counterpart. +#' @param method Deprecated. The former spelling of `walks`. +#' Still accepted, but warns; please use `walks` instead. #' @section Subgraph centrality: #' Subgraph centrality measures the participation of a node in all subgraphs #' in the network, giving higher weight to smaller subgraphs. @@ -351,22 +353,24 @@ node_by_hub <- function(.data, scaled = TRUE){ #' \doi{10.1103/PhysRevE.72.046105} #' @export node_by_subgraph <- function(.data, decay = 1, - method = c("all", "odd", "even")){ + walks = c("all", "odd", "even"), + method = NULL){ + walks <- resolve_method(walks, method, "walks") .data <- manynet::expect_nodes(.data) - method <- match.arg(method) + walks <- match.arg(walks, c("all", "odd", "even")) decay <- check_decay(decay) - out <- .closed_walks(.data, decay, method) + out <- .closed_walks(.data, decay, walks) # Subgraph centrality grows exponentially in the number of closed walks and # has no theoretical maximum, so no normalisation is offered. # Every node has one closed walk of length zero, itself, which the "odd" # count alone excludes. make_node_measure(out, .data, - measure = switch(method, + measure = switch(walks, all = "subgraph centrality", odd = "odd subgraph centrality", even = "even subgraph centrality"), - range = `if`(method == "odd", c(0, Inf), c(1, Inf)), - normalization = "none", variant = method) + range = `if`(walks == "odd", c(0, Inf), c(1, Inf)), + normalization = "none", variant = walks) } # Counts each node's closed walks, weighting a walk of length k by @@ -376,15 +380,15 @@ node_by_subgraph <- function(.data, decay = 1, # Shared by `node_by_subgraph()` and `net_by_bipartivity()`. # Unlike `igraph::subgraph_centrality()` this honours tie weights, which are # carried by the adjacency matrix itself. -.closed_walks <- function(.data, decay = 1, method = c("all", "odd", "even")) { - method <- match.arg(method) +.closed_walks <- function(.data, decay = 1, walks = c("all", "odd", "even")) { + walks <- match.arg(walks) mat <- manynet::as_matrix(manynet::to_multilevel(.data)) if(!isSymmetric(unname(mat))) { manynet::snet_info("Counting closed walks on the undirected form of this network, since the decomposition requires a symmetric matrix.") mat <- (mat + t(mat))/2 } eig <- eigen(mat, symmetric = TRUE) - weights <- switch(method, + weights <- switch(walks, all = exp(decay * eig$values), odd = sinh(decay * eig$values), even = cosh(decay * eig$values)) diff --git a/R/measure_closure.R b/R/measure_closure.R index b729efd..050e607 100644 --- a/R/measure_closure.R +++ b/R/measure_closure.R @@ -23,11 +23,16 @@ #' For three-mode networks, `net_congruency` calculates the proportion of three-paths #' spanning two two-mode networks that are closed by a fourth tie to establish a #' "congruent four-cycle" structure. +#' +#' `net_by_reciprocity()` takes a `variant`: either `"default"`, the share of +#' ties that are reciprocated, or `"ratio"`, the share of dyads that are mutual +#' rather than asymmetric. See `?igraph::reciprocity`. #' @template param_data #' @template net_measure #' @param object2 Optionally, a second (two-mode) matrix, igraph, or tidygraph -#' @param method For reciprocity, either `default` or `ratio`. -#' See `?igraph::reciprocity` +#' @template param_variant +#' @param method Deprecated. The former spelling of `variant`. +#' Still accepted, but warns; please use `variant` instead. NULL #' @rdname measure_closure @@ -35,16 +40,19 @@ NULL #' @examples #' net_by_reciprocity(ison_southern_women) #' @export -net_by_reciprocity <- function(.data, method = c("default", "ratio")) { +net_by_reciprocity <- function(.data, variant = c("default", "ratio"), + method = NULL) { + variant <- resolve_method(variant, method, "variant") .data <- manynet::expect_nodes(.data) - method <- match.arg(method) + variant <- match.arg(variant, c("default", "ratio")) # Both methods return a proportion in [0,1], but of different things: the # default is the share of ties that are reciprocated, the ratio the share of # dyads that are mutual rather than asymmetric. The variant says which. - make_network_measure(igraph::reciprocity(manynet::as_igraph(.data), mode = method), + make_network_measure(igraph::reciprocity(manynet::as_igraph(.data), + mode = variant), .data, call = deparse(sys.call()), measure = "reciprocity", range = c(0, 1), - normalization = "normalized", variant = method) + normalization = "normalized", variant = variant) } #' @rdname measure_closure diff --git a/R/measure_features.R b/R/measure_features.R index eb25797..57c538c 100644 --- a/R/measure_features.R +++ b/R/measure_features.R @@ -81,7 +81,12 @@ net_by_richclub <- function(.data){ } #' @rdname measure_features #' @param times Integer of number of simulations. -#' @param method There are three small-world measures implemented: +#' @template param_variant +#' @param method Deprecated. The former spelling of `variant`. +#' Still accepted, but warns; please use `variant` instead. +#' @section Small-world variants: +#' For `net_by_smallworld()` there are three small-world measures +#' implemented: #' - "sigma" is the original equation from Watts and Strogatz (1998), #' \deqn{\frac{\frac{C}{C_r}}{\frac{L}{L_r}}}, #' where \eqn{C} and \eqn{L} are the observed @@ -126,18 +131,18 @@ net_by_richclub <- function(.data){ #' net_by_smallworld(ison_southern_women) #' @export net_by_smallworld <- function(.data, - method = c("omega", "sigma", "SWI"), - times = 100) { - + variant = c("omega", "sigma", "SWI"), + times = 100, method = NULL) { + variant <- resolve_method(variant, method, "variant") .data <- manynet::expect_nodes(.data) - method <- match.arg(method) + variant <- match.arg(variant, c("omega", "sigma", "SWI")) if(manynet::is_twomode(.data)){ co <- net_by_equivalency(.data) cr <- mean(vapply(1:times, function(x) net_by_equivalency(manynet::generate_random(.data)), FUN.VALUE = numeric(1))) - if(method %in% c("omega", "SWI")){ + if(variant %in% c("omega", "SWI")){ cl <- net_by_equivalency(manynet::create_ring(.data)) } } else { @@ -145,7 +150,7 @@ net_by_smallworld <- function(.data, cr <- mean(vapply(1:times, function(x) net_by_transitivity(manynet::generate_random(.data)), FUN.VALUE = numeric(1))) - if(method %in% c("omega", "SWI")){ + if(variant %in% c("omega", "SWI")){ cl <- net_by_transitivity(manynet::create_lattice(.data)) } } @@ -154,25 +159,25 @@ net_by_smallworld <- function(.data, lr <- mean(vapply(1:times, function(x) net_by_length(manynet::generate_random(.data)), FUN.VALUE = numeric(1))) - if(method == "SWI"){ + if(variant == "SWI"){ ll <- net_by_length(manynet::create_ring(.data)) } - out <- switch(method, + out <- switch(variant, "omega" = (lr/lo - co/cl), "sigma" = (co/cr)/(lo/lr), "SWI" = ((lo - ll)/(lr - ll))*((co - cr)/(cl - cr))) make_network_measure(out, .data, call = deparse(sys.call()), measure = "small-world coefficient", - range = switch(method, + range = switch(variant, omega = c(-1, 1), sigma = c(0, Inf), SWI = c(0, 1)), # Only SWI is a proportion of a theoretical maximum; # omega is signed and sigma is an unbounded ratio. - normalization = `if`(method == "SWI", "normalized", "none"), - variant = method) + normalization = `if`(variant == "SWI", "normalized", "none"), + variant = variant) } #' @rdname measure_features #' @importFrom igraph fit_power_law @@ -229,7 +234,7 @@ net_by_scalefree <- function(.data){ #' whereas [manynet::is_twomode()] reports whether nodes are already #' partitioned into two modes. #' The node-level counterpart is [node_by_subgraph()] with -#' `method = "odd"` or `"even"`. +#' `walks = "odd"` or `"even"`. #' @references #' ## On bipartivity #' Estrada, Ernesto, and Juan A. Rodríguez-Velázquez. 2005. @@ -245,8 +250,8 @@ net_by_bipartivity <- function(.data) { .data <- manynet::expect_nodes(.data) # Even-length closed walks as a share of all of them. Both counts are # strictly positive, since the length-zero walk at each node is even. - out <- sum(.closed_walks(.data, method = "even")) / - sum(.closed_walks(.data, method = "all")) + out <- sum(.closed_walks(.data, walks = "even")) / + sum(.closed_walks(.data, walks = "all")) make_network_measure(out, .data, call = deparse(sys.call()), measure = "bipartivity", range = c(0, 1), normalization = "normalized") @@ -387,7 +392,11 @@ NULL #' @rdname measure_fit #' @param mark A logical vector indicating which nodes belong to the core. -#' @param method Which method of the following to use to calculate the fit of +#' @template param_variant +#' @param method Deprecated. The former spelling of `variant`. +#' Still accepted, but warns; please use `variant` instead. +#' @section Core-periphery fit variants: +#' For `net_by_core()`, which of the following to use to calculate the fit of #' the core assignment to a core-periphery model. #' "correlation" calculates the correlation between the empirical network and #' an ideal typical network, and "ident" calculates the Euclidean distances @@ -420,15 +429,17 @@ NULL #' @export net_by_core <- function(.data, mark = NULL, - method = c("correlation","ident","ndiff", "diff"), + variant = c("correlation","ident","ndiff", "diff"), coreness = NULL, - direction = c("all","out","in")){ + direction = c("all","out","in"), + method = NULL){ + variant <- resolve_method(variant, method, "variant") .data <- manynet::expect_nodes(.data) direction <- match.arg(direction) if(is.null(mark)) mark <- node_is_core(.data, coreness = coreness, direction = direction) - method <- match.arg(method) + variant <- match.arg(variant, c("correlation","ident","ndiff", "diff")) # `manynet::create_core()` returns an upper-triangular matrix for a directed # network rather than a directed core-periphery ideal, so comparing a # directed network against it would compare unlike with unlike. Both sides @@ -443,11 +454,11 @@ net_by_core <- function(.data, obs <- pmax(obs, t(obs)) ideal <- pmax(ideal, t(ideal)) } - if(method == "correlation"){ + if(variant == "correlation"){ out <- stats::cor(c(obs), c(ideal)) - } else if(method == "ident"){ + } else if(variant == "ident"){ out <- sqrt(sum((obs - ideal)^2)) - } else if(method %in% c("ndiff","diff")){ + } else if(variant %in% c("ndiff","diff")){ # Sort nodes by coreness c_scores <- node_by_core(.data, coreness = coreness, direction = direction) @@ -460,25 +471,25 @@ net_by_core <- function(.data, diff1 <- sum(min_core - periphery) diff2 <- sum(core - max_periphery) - if(method == "ndiff"){ + if(variant == "ndiff"){ out <- (diff1 + diff2) / length(c_scores) # Normalize - } else if(method == "diff"){ + } else if(variant == "diff"){ out <- (diff1 + diff2) * sqrt(sum(mark)) } - } else manynet::snet_unavailable(method) + } else manynet::snet_unavailable(variant) # The methods are on genuinely different scales: a correlation, a Euclidean # distance, and two signed differences in coreness, so each declares its own. make_network_measure(out, .data, call = deparse(sys.call()), - measure = switch(method, + measure = switch(variant, correlation = "core-periphery correlation", ident = "core-periphery distance", ndiff = "normalised core-periphery difference", diff = "core-periphery difference"), - range = switch(method, + range = switch(variant, correlation = c(-1, 1), ident = c(0, Inf), ndiff = , diff = c(-Inf, Inf)), - normalization = "none", variant = method) + normalization = "none", variant = variant) } #' @rdname measure_fit diff --git a/R/member_cliques.R b/R/member_cliques.R index d428ad6..f03c4b0 100644 --- a/R/member_cliques.R +++ b/R/member_cliques.R @@ -33,7 +33,9 @@ NULL #' @rdname member_cliques -#' @param num_groups An integer indicating the number of groups desired. +#' @param groups An integer indicating the number of groups desired. +#' @param num_groups Deprecated. The former spelling of `groups`. +#' Still accepted, but warns; please use `groups` instead. #' @param group_size An integer indicating the desired size of most of the groups. #' Note that if the number of nodes is not divisible into groups of equal size, #' there may be some larger or smaller groups. @@ -57,19 +59,25 @@ NULL #' _European Journal of Operational Research_ 289(3):1067–86. #' \doi{10.1016/j.ejor.2020.07.048}. #' @examples -#' node_in_roulette(ison_adolescents, num_groups = 3) +#' node_in_roulette(ison_adolescents, groups = 3) #' @export -node_in_roulette <- function(.data, num_groups, group_size, times = NULL){ +node_in_roulette <- function(.data, groups, group_size, times = NULL, + num_groups = NULL){ .data <- manynet::expect_nodes(.data) - if(missing(num_groups) & missing(group_size)){ - manynet::snet_abort(paste("Either `num_groups` must indicate number of groups desired", - "or `group_size` must indicate the desired average size of groups.")) + # Read before `resolve_groups()` assigns, since assigning to a formal that + # was missing makes `missing()` FALSE from then on. + has_groups <- !missing(groups) || !is.null(num_groups) + groups <- resolve_groups(if(missing(groups)) NULL else groups, num_groups) + if(!has_groups & missing(group_size)){ + manynet::snet_abort("Either {.arg groups} must indicate the number of groups", + "desired, or {.arg group_size} the desired average size", + "of the groups.") } n <- manynet::net_nodes(.data) my_vec <- sample(seq.int(n)) # Initial partition - if(!missing(num_groups)){ - out <- cut(seq_along(my_vec), num_groups, labels = FALSE)[my_vec] + if(has_groups){ + out <- cut(seq_along(my_vec), groups, labels = FALSE)[my_vec] } else { out <- ceiling(seq_along(my_vec) / group_size)[my_vec] } diff --git a/R/member_community.R b/R/member_community.R index d6d78ca..b9ca101 100644 --- a/R/member_community.R +++ b/R/member_community.R @@ -128,11 +128,11 @@ strict_memb <- function(.data){ # Resolves `k` for one algorithm. # `at_k(no)` returns a membership vector with `no` groups, # and `default()` returns the algorithm's own partition. -apply_k <- function(k, Kmax, .data, at_k, default){ +apply_k <- function(k, max_k, .data, at_k, default){ n <- manynet::net_nodes(.data) memb <- if(is.null(k)) default() else if(identical(k, "strict")) strict_memb(.data) else - if(is.character(k)) select_k(lapply(2:min(Kmax, n), at_k), .data, k) else + if(is.character(k)) select_k(lapply(2:min(max_k, n), at_k), .data, k) else at_k(k) report_k(memb, k) } @@ -188,9 +188,9 @@ poss_algs <- function(k, .data){ # Runs one algorithm by name. # Where `k` was requested the algorithm warns when it cannot reach it, which # the caller reports once instead. -run_alg <- function(alg, .data, k, Kmax){ +run_alg <- function(alg, .data, k, max_k){ if(is.null(k)) get(alg)(.data) else - suppressWarnings(get(alg)(.data, k = k, Kmax = Kmax)) + suppressWarnings(get(alg)(.data, k = k, max_k = max_k)) } # The algorithms that return a different partition on a second run. @@ -208,7 +208,7 @@ coassociation <- function(parts, n){ # Combines many partitions into one, after Lancichinetti and Fortunato (2012). # The algorithms are rerun on the co-association matrix until every pair either # always or never shares a group, at which point the groups are its components. -consensus_memb <- function(.data, k, Kmax, times, threshold = 0.5, iter = 10){ +consensus_memb <- function(.data, k, max_k, times, threshold = 0.5, iter = 10){ n <- manynet::net_nodes(.data) gr <- .data cons <- NULL @@ -216,7 +216,7 @@ consensus_memb <- function(.data, k, Kmax, times, threshold = 0.5, iter = 10){ algs <- poss_algs(k, gr) parts <- unlist(lapply(algs, function(alg){ reps <- if(alg %in% STOCHASTIC_ALGS) times else 1L - lapply(seq_len(reps), function(r) run_alg(alg, gr, k, Kmax)) + lapply(seq_len(reps), function(r) run_alg(alg, gr, k, max_k)) }), recursive = FALSE) cons <- coassociation(parts, n) cons[cons < threshold] <- 0 @@ -282,8 +282,9 @@ NULL #' @examples #' node_in_community(ison_adolescents) #' @export -node_in_community <- function(.data, k = NULL, Kmax = 8L, - consensus = FALSE, times = 20){ +node_in_community <- function(.data, k = NULL, max_k = 8L, + consensus = FALSE, times = 20, Kmax = NULL){ + 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){ @@ -299,7 +300,7 @@ node_in_community <- function(.data, k = NULL, Kmax = 8L, # consensus for every candidate number of groups. The constituent # algorithms are given `k` instead, and the result merged only if it # overshoots. - memb <- consensus_memb(.data, k, Kmax, times) + memb <- consensus_memb(.data, k, max_k, times) if(is.numeric(k) && length(unique(memb)) > k) memb <- merge_to_k(.data, memb, k) make_node_member(report_k(memb, k), .data) @@ -311,7 +312,7 @@ node_in_community <- function(.data, k = NULL, Kmax = 8L, idx <- manynet::snet_progress_along(poss) if(length(idx) != length(poss)) idx <- seq_along(poss) candidates <- lapply(idx, function(comm){ - memb <- run_alg(poss[comm], .data, k, Kmax) + memb <- run_alg(poss[comm], .data, k, max_k) mod <- net_by_modularity(.data, memb) list(memb, mod) }) @@ -407,13 +408,14 @@ node_in_optimal <- function(.data){ #' node_in_partition(ison_adolescents) #' node_in_partition(ison_southern_women) #' @export -node_in_partition <- function(.data, k = 2L, Kmax = 8L){ +node_in_partition <- function(.data, k = 2L, max_k = 8L, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) k <- check_k(k, .data) n <- manynet::net_nodes(.data) g <- manynet::as_matrix(manynet::to_multilevel(.data)) at_k <- function(no) kl_partition(g, n, no) - memb <- apply_k(k, Kmax, .data, at_k = at_k, default = function() at_k(2L)) + memb <- apply_k(k, max_k, .data, at_k = at_k, default = function() at_k(2L)) make_node_member(memb, .data) } @@ -492,13 +494,14 @@ node_in_infomap <- function(.data, times = 50){ } #' @rdname member_community_non -#' @param max_k Integer constant, the number of spins to use as an upper limit -#' of communities to be found. Some sets can be empty at the end. #' @param resolution The Reichardt-Bornholdt “gamma” resolution parameter for modularity. #' By default 1, making existing and non-existing ties equally important. #' Smaller values make existing ties more important, #' and larger values make missing ties more important. #' @section Spin-glass: +#' Here `max_k` is the number of spins, an upper limit on the communities +#' found rather than a bound on a search, so some can end up empty. +#' #' This is motivated by analogy to the Potts model in statistical physics. #' Each node can be in one of _k_ "spin states", #' and ties (particle interactions) provide information about which pairs of nodes @@ -555,7 +558,8 @@ node_in_spinglass <- function(.data, max_k = 200, resolution = 1){ #' @examples #' node_in_fluid(ison_adolescents) #' @export -node_in_fluid <- function(.data, k = NULL, Kmax = 8L) { +node_in_fluid <- function(.data, k = NULL, max_k = 8L, Kmax = NULL) { + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) k <- check_k(k, .data) .data <- manynet::as_igraph(.data) @@ -578,7 +582,7 @@ node_in_fluid <- function(.data, k = NULL, Kmax = 8L) { } at_k <- function(no) igraph::membership( igraph::cluster_fluid_communities(.data, no.of.communities = no)) - memb <- apply_k(k, Kmax, .data, at_k = at_k, default = function(){ + memb <- apply_k(k, max_k, .data, at_k = at_k, default = function(){ mods <- vapply(seq_nodes(.data), function(x) igraph::modularity(.data, membership = igraph::membership( igraph::cluster_fluid_communities(.data, x))), @@ -607,7 +611,8 @@ node_in_fluid <- function(.data, k = NULL, Kmax = 8L) { #' @examples #' node_in_louvain(ison_adolescents) #' @export -node_in_louvain <- function(.data, k = NULL, Kmax = 8L, resolution = 1){ +node_in_louvain <- function(.data, k = NULL, max_k = 8L, resolution = 1, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) k <- check_k(k, .data) if(manynet::is_directed(.data)){ @@ -616,7 +621,7 @@ node_in_louvain <- function(.data, k = NULL, Kmax = 8L, resolution = 1){ .data <- manynet::to_undirected(.data) } gr <- manynet::as_igraph(.data) - memb <- apply_k(k, Kmax, .data, + memb <- apply_k(k, max_k, .data, at_k = function(no) cut_res(igraph::cluster_louvain, gr, no), default = function() igraph::cluster_louvain(gr, resolution = resolution)$membership) @@ -651,7 +656,8 @@ node_in_louvain <- function(.data, k = NULL, Kmax = 8L, resolution = 1){ #' @examples #' node_in_leiden(ison_adolescents) #' @export -node_in_leiden <- function(.data, k = NULL, Kmax = 8L, resolution = 1){ +node_in_leiden <- function(.data, k = NULL, max_k = 8L, resolution = 1, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) k <- check_k(k, .data) if(manynet::is_directed(.data)){ @@ -664,7 +670,7 @@ node_in_leiden <- function(.data, k = NULL, Kmax = 8L, resolution = 1){ resolution <- sum(manynet::tie_weights(.data))/(n*(n - 1)/2) } gr <- manynet::as_igraph(.data) - memb <- apply_k(k, Kmax, .data, + memb <- apply_k(k, max_k, .data, at_k = function(no) cut_res(igraph::cluster_leiden, gr, no), default = function() igraph::cluster_leiden(gr, resolution = resolution)$membership) @@ -705,7 +711,8 @@ node_in_leiden <- function(.data, k = NULL, Kmax = 8L, resolution = 1){ #' @examples #' node_in_labels(ison_adolescents) #' @export -node_in_labels <- function(.data, k = NULL, Kmax = 8L){ +node_in_labels <- function(.data, k = NULL, max_k = 8L, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) k <- check_k(k, .data) if(manynet::is_directed(.data)){ @@ -727,7 +734,7 @@ node_in_labels <- function(.data, k = NULL, Kmax = 8L){ gr, initial = init, fixed = fixed)$membership) merge_to_k(.data, memb, no) } - memb <- apply_k(k, Kmax, .data, at_k = at_k, + memb <- apply_k(k, max_k, .data, at_k = at_k, default = function() igraph::cluster_label_prop(gr)$membership) make_node_member(memb, .data) } @@ -778,7 +785,8 @@ NULL #' @examples #' node_in_betweenness(ison_adolescents) #' @export -node_in_betweenness <- function(.data, k = NULL, Kmax = 8L){ +node_in_betweenness <- function(.data, k = NULL, max_k = 8L, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) k <- check_k(k, .data) if(manynet::net_nodes(.data)>100) @@ -786,7 +794,7 @@ node_in_betweenness <- function(.data, k = NULL, Kmax = 8L){ "or even run out of memory on such a large network.") clust <- suppressWarnings(igraph::cluster_edge_betweenness( manynet::as_igraph(.data))) - memb <- apply_k(k, Kmax, .data, + memb <- apply_k(k, max_k, .data, at_k = function(no) cut_tree(clust, no), default = function() clust$membership) out <- make_node_member(memb, .data) @@ -814,11 +822,12 @@ node_in_betweenness <- function(.data, k = NULL, Kmax = 8L){ #' @examples #' node_in_greedy(ison_adolescents) #' @export -node_in_greedy <- function(.data, k = NULL, Kmax = 8L){ +node_in_greedy <- function(.data, k = NULL, max_k = 8L, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) k <- check_k(k, .data) clust <- igraph::cluster_fast_greedy(manynet::to_undirected(manynet::as_igraph(.data))) - memb <- apply_k(k, Kmax, .data, + memb <- apply_k(k, max_k, .data, at_k = function(no) cut_tree(clust, no), default = function() clust$membership) out <- make_node_member(memb, .data) @@ -845,7 +854,8 @@ node_in_greedy <- function(.data, k = NULL, Kmax = 8L){ #' @examples #' node_in_eigen(ison_adolescents) #' @export -node_in_eigen <- function(.data, k = NULL, Kmax = 8L){ +node_in_eigen <- function(.data, k = NULL, max_k = 8L, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) k <- check_k(k, .data) if(manynet::is_directed(.data)){ @@ -854,7 +864,7 @@ node_in_eigen <- function(.data, k = NULL, Kmax = 8L){ .data <- manynet::to_undirected(.data) } clust <- igraph::cluster_leading_eigen(manynet::as_igraph(.data)) - memb <- apply_k(k, Kmax, .data, + memb <- apply_k(k, max_k, .data, at_k = function(no) cut_tree(clust, no), default = function() clust$membership) out <- make_node_member(memb, .data) @@ -881,11 +891,12 @@ node_in_eigen <- function(.data, k = NULL, Kmax = 8L){ #' @examples #' node_in_walktrap(ison_adolescents) #' @export -node_in_walktrap <- function(.data, k = NULL, Kmax = 8L, steps = 4){ +node_in_walktrap <- function(.data, k = NULL, max_k = 8L, steps = 4, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) k <- check_k(k, .data) clust <- igraph::cluster_walktrap(manynet::as_igraph(.data), steps = steps) - memb <- apply_k(k, Kmax, .data, + memb <- apply_k(k, max_k, .data, at_k = function(no) cut_tree(clust, no), default = function() clust$membership) out <- make_node_member(memb, .data) diff --git a/R/member_core.R b/R/member_core.R index 2708297..018423f 100644 --- a/R/member_core.R +++ b/R/member_core.R @@ -148,9 +148,12 @@ NULL #' @rdname member_core #' @param groups Number of categories to create. Must be at least 2 and at most #' the number of nodes in the network. Default is 3. -#' @param cluster_by Method to use to create the categories. -#' One of "bins" (equal-width bins), "quantiles" (quantile-based bins), -#' or "kmeans" (k-means clustering). Default is "bins". +#' @param split Which method to use to split the coreness scores into the +#' categories. One of "bins" (equal-width bins), "quantiles" +#' (quantile-based bins), or "kmeans" (k-means clustering); +#' see [method_split] for what each does. Default is "bins". +#' @param cluster_by Deprecated. The former spelling of `split`. +#' Still accepted, but warns; please use `split` instead. #' @param coreness Which method to use to calculate nodes' coreness. #' One of "correlation", "rich", "transition", or "hub"; #' see [method_coreness] for what each does. @@ -179,7 +182,7 @@ NULL #' - "Receiver" for nodes in the in-core only, #' - "Periphery" for nodes in neither. #' -#' This uses [coreness_hub()], so `groups` and `cluster_by` do not apply. +#' This uses [coreness_hub()], so `groups` and `split` do not apply. #' @references #' ## On core-periphery categorization #' Wallerstein, Immanuel. 1974. @@ -198,9 +201,11 @@ NULL #' node_in_core(ison_networkers, direction = "both") #' @export node_in_core <- function(.data, groups = 3, - cluster_by = c("bins","quantiles","kmeans"), + split = c("bins","quantiles","kmeans"), coreness = NULL, - direction = c("all","out","in","both")) { + direction = c("all","out","in","both"), + cluster_by = NULL) { + split <- resolve_split(split, cluster_by) .data <- manynet::expect_nodes(.data) direction <- match.arg(direction) if(direction == "both") return(.core_four_sets(.data)) @@ -209,21 +214,12 @@ node_in_core <- function(.data, groups = 3, manynet::snet_abort("{.arg groups} cannot exceed the number of nodes.") contin <- as.numeric(node_by_core(.data, coreness = coreness, direction = direction)) - cluster_by <- match.arg(cluster_by) - out <- switch(cluster_by, - bins = cut(contin, breaks = groups, labels = FALSE), - quantiles = as.numeric(cut(contin, - breaks = stats::quantile(contin, - probs = seq(0, 1, length.out = groups + 1)), - include.lowest = TRUE, labels = FALSE)), - # k-means numbers its clusters in whatever order it finds - # them, so the numbers must be put back in coreness order - # before they can index the labels. - kmeans = { - km <- stats::kmeans(contin, centers = groups) - order(order(km$centers))[km$cluster] - } - ) + split <- match.arg(split, c("bins","quantiles","kmeans")) + manynet::snet_info("Splitting the coreness scores using {.fn split_{split}}.") + out <- switch(split, + bins = split_bins(contin, groups), + quantiles = split_quantiles(contin, groups), + kmeans = split_kmeans(contin, groups)) out <- rev(core_labels(groups))[out] make_node_member(out, .data) } diff --git a/R/member_equivalence.R b/R/member_equivalence.R index a619982..715a2a4 100644 --- a/R/member_equivalence.R +++ b/R/member_equivalence.R @@ -41,9 +41,11 @@ #' By default `"euclidean"`, but other options include #' `"maximum"`, `"manhattan"`, `"canberra"`, `"binary"`, and `"minkowski"`. #' Fewer, identifiable letters, e.g. `"e"` for Euclidean, is sufficient. -#' @param Kmax Integer indicating the maximum number of (k) clusters +#' @param max_k Integer indicating the maximum number of (k) clusters #' to evaluate. #' Ignored when `k = "strict"` or a discrete number is given for `k`. +#' @param Kmax Deprecated. The former spelling of `max_k`. +#' Still accepted, but warns; please use `max_k` instead. #' @importFrom stats as.dist hclust cutree coef cor median #' @source \url{https://github.com/aslez/concoR} NULL @@ -55,7 +57,8 @@ node_in_equivalence <- function(.data, motif, cluster = c("hierarchical", "concor", "cosine"), distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), - Kmax = 8L){ + max_k = 8L, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) cluster <- match.arg(cluster) manynet::snet_info("Clustering using {.fn cluster_{cluster}}.") @@ -71,8 +74,8 @@ node_in_equivalence <- function(.data, motif, manynet::snet_info("Selecting the number of clusters using {.fn k_{k}}.") k <- switch(k, strict = k_strict(hc, .data), - elbow = k_elbow(hc, .data, motif, Kmax), - silhouette = k_silhouette(hc, .data, Kmax)) + elbow = k_elbow(hc, .data, motif, max_k), + silhouette = k_silhouette(hc, .data, max_k)) } if(length(k)==0) k <- 1 # in the case of all nodes being in the same cluster @@ -91,7 +94,8 @@ node_in_structural <- function(.data, cluster = c("hierarchical", "concor","cosine"), distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), - Kmax = 8L){ + max_k = 8L, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) mat <- node_x_tie(.data) if(any(colSums(t(mat))==0)){ @@ -99,7 +103,7 @@ node_in_structural <- function(.data, } node_in_equivalence(.data, mat, k = k, cluster = cluster, distance = distance, - Kmax = Kmax) + max_k = max_k) } #' @rdname member_equivalence @@ -136,9 +140,10 @@ node_in_regular <- function(.data, cluster = c("hierarchical", "concor","cosine"), distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), - Kmax = 8L, + max_k = 8L, regularity = c("rolesim", "rege"), - decay = 0.15, beta = NULL){ + decay = 0.15, beta = NULL, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) regularity <- match.arg(regularity) decay <- resolve_decay(decay, beta, "beta") @@ -148,7 +153,7 @@ node_in_regular <- function(.data, rolesim = regularity_rolesim(.data, decay = decay), rege = regularity_rege(.data)) node_in_equivalence(.data, mat, - k = k, cluster = cluster, distance = distance, Kmax = Kmax) + k = k, cluster = cluster, distance = distance, max_k = max_k) } #' @rdname member_equivalence @@ -172,7 +177,8 @@ node_in_motif <- function(.data, cluster = c("hierarchical", "concor","cosine"), distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), - Kmax = 8L){ + max_k = 8L, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) if(manynet::is_twomode(.data)){ manynet::snet_info("Since this is a two-mode network,", @@ -187,7 +193,7 @@ node_in_motif <- function(.data, } if(any(colSums(mat) == 0)) mat <- mat[,-which(colSums(mat) == 0)] node_in_equivalence(.data, mat, - k = k, cluster = cluster, distance = distance, Kmax = Kmax) + k = k, cluster = cluster, distance = distance, max_k = max_k) } #' @rdname member_equivalence @@ -202,11 +208,12 @@ node_in_automorphic <- function(.data, cluster = c("hierarchical", "concor","cosine"), distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), - Kmax = 8L){ + max_k = 8L, Kmax = NULL){ + max_k <- resolve_max_k(max_k, Kmax) .data <- manynet::expect_nodes(.data) mat <- node_x_path(.data) node_in_equivalence(.data, mat, - k = k, cluster = cluster, distance = distance, Kmax = Kmax) + k = k, cluster = cluster, distance = distance, max_k = max_k) } #' @rdname member_equivalence diff --git a/R/method_k.R b/R/method_k.R index 27bbbaf..ef687b4 100644 --- a/R/method_k.R +++ b/R/method_k.R @@ -64,7 +64,7 @@ k_strict <- function(hc, .data){ #' @rdname method_kselect #' @param motif A motif census object. -#' @param Kmax An integer indicating the maximum number of options to consider. +#' @param max_k An integer indicating the maximum number of options to consider. #' The minimum of this and the number of nodes in the network is used. #' @section Elbow method: #' The elbow method is a heuristic used in cluster analysis to determine the optimal number of clusters. @@ -89,7 +89,7 @@ k_strict <- function(hc, .data){ #' _Psychometrika_, 18(4): 267–76. #' \doi{10.1007/BF02289263}. #' @export -k_elbow <- function(hc, .data, motif, Kmax){ +k_elbow <- function(hc, .data, motif, max_k){ thisRequires("sna") @@ -115,7 +115,7 @@ k_elbow <- function(hc, .data, motif, Kmax){ resultlist <- list() correlations <- vector() - for (i in 2:min(Kmax, vertices)) { + for (i in 2:min(max_k, vertices)) { cluster_result <- list(label = NA, clusters = NA, correlation = NA) cluster_result$label <- paste("number of clusters: ", i) @@ -129,7 +129,7 @@ k_elbow <- function(hc, .data, motif, Kmax){ } resultlist$correlations <- c(correlations) - dafr <- data.frame(clusters = 2:min(Kmax, vertices), + dafr <- data.frame(clusters = 2:min(max_k, vertices), correlations = c(correlations)) correct <- NULL # to satisfy the error god @@ -163,10 +163,10 @@ k_elbow <- function(hc, .data, motif, Kmax){ #' _Journal of Computational and Applied Mathematics_, 20: 53–65. #' \doi{10.1016/0377-0427(87)90125-7}. #' @export -k_silhouette <- function(hc, .data, Kmax){ - if(missing(Kmax)) Kmax <- length(hc$order) else - Kmax <- min(Kmax, length(hc$order)) - kcs <- 2:min(Kmax, manynet::net_nodes(.data)) +k_silhouette <- function(hc, .data, max_k){ + if(missing(max_k)) max_k <- length(hc$order) else + max_k <- min(max_k, length(hc$order)) + kcs <- 2:min(max_k, manynet::net_nodes(.data)) ns <- seq_len(manynet::net_nodes(.data)) distances <- hc$distances ks <- vector() @@ -197,10 +197,10 @@ k_silhouette <- function(hc, .data, Kmax){ #' @param sims Integer of how many simulations should be generated as a #' reference distribution. #' @export -k_gap <- function(hc, motif, Kmax, sims = 100) { +k_gap <- function(hc, motif, max_k, sims = 100) { - if(missing(Kmax)) Kmax <- length(hc$order) else - Kmax <- min(Kmax, length(hc$order)) + if(missing(max_k)) max_k <- length(hc$order) else + max_k <- min(max_k, length(hc$order)) # --- helper: within-cluster dispersion Wk --- within_disp <- function(motif, clusters) { @@ -220,11 +220,11 @@ k_gap <- function(hc, motif, Kmax, sims = 100) { maxs <- apply(motif, 2, max) # storage - logW <- numeric(Kmax) - logW_ref <- matrix(0, nrow = sims, ncol = Kmax) + logW <- numeric(max_k) + logW_ref <- matrix(0, nrow = sims, ncol = max_k) # --- real data W_k --- - for (k in 1:Kmax) { + for (k in 1:max_k) { cl <- cutree(hc, k) logW[k] <- log(within_disp(motif, cl)) } @@ -235,7 +235,7 @@ k_gap <- function(hc, motif, Kmax, sims = 100) { d_ref <- stats::dist(ref) hc_ref <- hclust(d_ref, method = hc$method) - for (k in 1:Kmax) { + for (k in 1:max_k) { cl_ref <- cutree(hc_ref, k) logW_ref[b, k] <- log(within_disp(ref, cl_ref)) } @@ -246,7 +246,7 @@ k_gap <- function(hc, motif, Kmax, sims = 100) { se <- sqrt(1 + 1/B) * apply(logW_ref, 2, stats::sd) # --- Tibshirani 1-SE rule --- - k <- which(gap[-Kmax] >= gap[-1] - se[-1])[1] + k <- which(gap[-max_k] >= gap[-1] - se[-1])[1] k } diff --git a/R/motif_brokerage.R b/R/motif_brokerage.R index 6b68a38..b83cc02 100644 --- a/R/motif_brokerage.R +++ b/R/motif_brokerage.R @@ -10,9 +10,7 @@ #' @template param_memb #' @family brokerage #' @template node_motif -#' @param standardized Whether the score should be standardized -#' into a _z_-score indicating how many standard deviations above -#' or below the average the score lies. +#' @template param_standardized NULL #' @rdname motif_brokerage_node @@ -61,9 +59,7 @@ node_x_brokerage <- function(.data, membership, standardized = FALSE){ #' @template param_memb #' @family brokerage #' @template net_motif -#' @param standardized Whether the score should be standardized -#' into a _z_-score indicating how many standard deviations above -#' or below the average the score lies. +#' @template param_standardized NULL #' @rdname motif_brokerage_net diff --git a/man-roxygen/param_k.R b/man-roxygen/param_k.R index b764e20..d1aff62 100644 --- a/man-roxygen/param_k.R +++ b/man-roxygen/param_k.R @@ -10,9 +10,11 @@ #' coverage curve has no clear elbow. #' If the algorithm cannot return exactly the number of communities #' requested, a warning is given and the nearest number is returned. -#' @param Kmax Integer indicating the maximum number of communities to +#' @param max_k Integer indicating the maximum number of communities to #' evaluate for `"silhouette"` and `"elbow"`. By default `8`. #' Otherwise ignored. #' Note that for `node_in_louvain()` and `node_in_leiden()` each candidate #' requires its own search over the resolution parameter, -#' so a large `Kmax` is costly on large networks. +#' so a large `max_k` is costly on large networks. +#' @param Kmax Deprecated. The former spelling of `max_k`. +#' Still accepted, but warns; please use `max_k` instead. diff --git a/man-roxygen/param_standardized.R b/man-roxygen/param_standardized.R new file mode 100644 index 0000000..2276e6f --- /dev/null +++ b/man-roxygen/param_standardized.R @@ -0,0 +1,7 @@ +#' @param standardized Logical scalar. Where `TRUE`, the counts are returned +#' as z-scores against a null model rather than as raw counts. +#' This is a different quantity from `normalized`, which divides by a +#' theoretical maximum, and from `scaled`, which divides by the observed +#' maximum: a z-score says how far the count departs from what the null +#' model expects, so it can be negative and has no fixed range. +#' By default `FALSE`. diff --git a/man-roxygen/param_variant.R b/man-roxygen/param_variant.R new file mode 100644 index 0000000..42f903d --- /dev/null +++ b/man-roxygen/param_variant.R @@ -0,0 +1,3 @@ +#' @param variant Character string naming which variant of the measure to +#' compute, where more than one definition of the same quantity is in use. +#' The variant chosen is reported when the result is printed. diff --git a/man/measure_central_eigen.Rd b/man/measure_central_eigen.Rd index cca89f4..fa370ba 100644 --- a/man/measure_central_eigen.Rd +++ b/man/measure_central_eigen.Rd @@ -30,7 +30,12 @@ node_by_authority(.data, scaled = TRUE) node_by_hub(.data, scaled = TRUE) -node_by_subgraph(.data, decay = 1, method = c("all", "odd", "even")) +node_by_subgraph( + .data, + decay = 1, + walks = c("all", "odd", "even"), + method = NULL +) node_by_posneg(.data) } @@ -65,13 +70,16 @@ so each documents its own default.} \item{alpha}{Deprecated; use \code{decay} instead.} -\item{method}{Character string indicating which closed walks to count. +\item{walks}{Character string indicating which closed walks to count. By default \code{"all"}, which is subgraph centrality as usually defined. \code{"odd"} counts only walks of odd length and \code{"even"} only those of even length; the two sum to \code{"all"}. Odd closed walks cannot occur within a bipartite structure, so a node scoring near zero on \code{"odd"} sits in a locally two-mode-like neighbourhood. See \code{\link[=net_by_bipartivity]{net_by_bipartivity()}} for the network-level counterpart.} + +\item{method}{Deprecated. The former spelling of \code{walks}. +Still accepted, but warns; please use \code{walks} instead.} } \value{ A \code{node_measure} numeric vector the length of the nodes in the network, diff --git a/man/measure_closure.Rd b/man/measure_closure.Rd index 54547f6..f8c90b1 100644 --- a/man/measure_closure.Rd +++ b/man/measure_closure.Rd @@ -9,7 +9,7 @@ \alias{net_by_congruency} \title{Measuring network closure} \usage{ -net_by_reciprocity(.data, method = c("default", "ratio")) +net_by_reciprocity(.data, variant = c("default", "ratio"), method = NULL) net_by_transitivity(.data) @@ -24,8 +24,12 @@ net_by_congruency(.data, object2) 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{method}{For reciprocity, either \code{default} or \code{ratio}. -See \code{?igraph::reciprocity}} +\item{variant}{Character string naming which variant of the measure to +compute, where more than one definition of the same quantity is in use. +The variant chosen is reported when the result is printed.} + +\item{method}{Deprecated. The former spelling of \code{variant}. +Still accepted, but warns; please use \code{variant} instead.} \item{object2}{Optionally, a second (two-mode) matrix, igraph, or tidygraph} } @@ -61,6 +65,10 @@ that are closed by fourth tie to establish a "shared four-cycle" structure. For three-mode networks, \code{net_congruency} calculates the proportion of three-paths spanning two two-mode networks that are closed by a fourth tie to establish a "congruent four-cycle" structure. + +\code{net_by_reciprocity()} takes a \code{variant}: either \code{"default"}, the share of +ties that are reciprocated, or \code{"ratio"}, the share of dyads that are mutual +rather than asymmetric. See \code{?igraph::reciprocity}. } \section{Cyclicality}{ diff --git a/man/measure_features.Rd b/man/measure_features.Rd index 74f2814..3bf9a0a 100644 --- a/man/measure_features.Rd +++ b/man/measure_features.Rd @@ -14,7 +14,12 @@ \usage{ net_by_richclub(.data) -net_by_smallworld(.data, method = c("omega", "sigma", "SWI"), times = 100) +net_by_smallworld( + .data, + variant = c("omega", "sigma", "SWI"), + times = 100, + method = NULL +) net_by_scalefree(.data) @@ -27,32 +32,14 @@ net_by_balance(.data) 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{method}{There are three small-world measures implemented: -\itemize{ -\item "sigma" is the original equation from Watts and Strogatz (1998), -\deqn{\frac{\frac{C}{C_r}}{\frac{L}{L_r}}}, -where \eqn{C} and \eqn{L} are the observed -clustering coefficient and path length, respectively, -and \eqn{C_r} and \eqn{L_r} are the averages obtained from -random networks of the same dimensions and density. -A \eqn{\sigma > 1} is considered to be small-world, -but this measure is highly sensitive to network size. -\item "omega" (the default) is an update from Telesford et al. (2011), -\deqn{\frac{L_r}{L} - \frac{C}{C_l}}, -where \eqn{C_l} is the clustering coefficient for a lattice graph -with the same dimensions. -\eqn{\omega} ranges between -1 and 1, where values close to 0 are -as close to a small-world as possible; negative values indicate a -lattice-like network, and positive values a more random one. -\item "SWI" is an alternative proposed by Neal (2017), -\deqn{\frac{L - L_l}{L_r - L_l} \times \frac{C - C_r}{C_l - C_r}}, -where \eqn{L_l} is the average path length for a lattice graph -with the same dimensions. -\eqn{SWI} ranges between 0 and 1, where 1 is as close to a small-world -as possible, though there may not be a network for which \eqn{SWI = 1}. -}} +\item{variant}{Character string naming which variant of the measure to +compute, where more than one definition of the same quantity is in use. +The variant chosen is reported when the result is printed.} \item{times}{Integer of number of simulations.} + +\item{method}{Deprecated. The former spelling of \code{variant}. +Still accepted, but warns; please use \code{variant} instead.} } \value{ A \code{network_measure} numeric score. @@ -84,6 +71,35 @@ ranging between \code{0} if all triangles are imbalanced and bipartite, that is, to dividing into two sets with ties only between them. } } +\section{Small-world variants}{ + +For \code{net_by_smallworld()} there are three small-world measures +implemented: +\itemize{ +\item "sigma" is the original equation from Watts and Strogatz (1998), +\deqn{\frac{\frac{C}{C_r}}{\frac{L}{L_r}}}, +where \eqn{C} and \eqn{L} are the observed +clustering coefficient and path length, respectively, +and \eqn{C_r} and \eqn{L_r} are the averages obtained from +random networks of the same dimensions and density. +A \eqn{\sigma > 1} is considered to be small-world, +but this measure is highly sensitive to network size. +\item "omega" (the default) is an update from Telesford et al. (2011), +\deqn{\frac{L_r}{L} - \frac{C}{C_l}}, +where \eqn{C_l} is the clustering coefficient for a lattice graph +with the same dimensions. +\eqn{\omega} ranges between -1 and 1, where values close to 0 are +as close to a small-world as possible; negative values indicate a +lattice-like network, and positive values a more random one. +\item "SWI" is an alternative proposed by Neal (2017), +\deqn{\frac{L - L_l}{L_r - L_l} \times \frac{C - C_r}{C_l - C_r}}, +where \eqn{L_l} is the average path length for a lattice graph +with the same dimensions. +\eqn{SWI} ranges between 0 and 1, where 1 is as close to a small-world +as possible, though there may not be a network for which \eqn{SWI = 1}. +} +} + \section{Bipartivity}{ A network is bipartite when its nodes divide into two sets with ties only @@ -100,7 +116,7 @@ not whether it has been: it is defined on a one-mode network, whereas \code{\link[manynet:is_twomode]{manynet::is_twomode()}} reports whether nodes are already partitioned into two modes. The node-level counterpart is \code{\link[=node_by_subgraph]{node_by_subgraph()}} with -\code{method = "odd"} or \code{"even"}. +\code{walks = "odd"} or \code{"even"}. } \examples{ diff --git a/man/measure_fit.Rd b/man/measure_fit.Rd index 52a0698..2688ceb 100644 --- a/man/measure_fit.Rd +++ b/man/measure_fit.Rd @@ -11,9 +11,10 @@ net_by_core( .data, mark = NULL, - method = c("correlation", "ident", "ndiff", "diff"), + variant = c("correlation", "ident", "ndiff", "diff"), coreness = NULL, - direction = c("all", "out", "in") + direction = c("all", "out", "in"), + method = NULL ) net_by_factions(.data, membership = NULL) @@ -29,16 +30,9 @@ For more information on possible coercions, see e.g. \code{\link[manynet:as_stoc \item{mark}{A logical vector indicating which nodes belong to the core.} -\item{method}{Which method of the following to use to calculate the fit of -the core assignment to a core-periphery model. -"correlation" calculates the correlation between the empirical network and -an ideal typical network, and "ident" calculates the Euclidean distances -between the same. -"ndiff", however, calculates how distinct the core and periphery groups are -based on the difference in coreness scores between the least core-like -member of the core and the most core-like member of the periphery. -"diff" is similar to "ndiff", but multiplies the raw "ndiff" score by the -square root of the size of the core, thus penalising large cores.} +\item{variant}{Character string naming which variant of the measure to +compute, where more than one definition of the same quantity is in use. +The variant chosen is reported when the result is printed.} \item{coreness}{Which method to use to calculate nodes' coreness. One of "correlation", "rich", "transition", or "hub"; @@ -52,6 +46,9 @@ For a directed network, "out" scores nodes on the ties they send and "in" on the ties they receive. Ignored for undirected and two-mode networks.} +\item{method}{Deprecated. The former spelling of \code{variant}. +Still accepted, but warns; please use \code{variant} instead.} + \item{membership}{A character string naming an existing node attribute in the network, or a categorical vector of the same length as the number of nodes in the network where each element indicates the group membership of @@ -112,6 +109,20 @@ direction, so they are not interchangeable:\tabular{llll}{ Compare partitions using one measure at a time. } +\section{Core-periphery fit variants}{ + +For \code{net_by_core()}, which of the following to use to calculate the fit of +the core assignment to a core-periphery model. +"correlation" calculates the correlation between the empirical network and +an ideal typical network, and "ident" calculates the Euclidean distances +between the same. +"ndiff", however, calculates how distinct the core and periphery groups are +based on the difference in coreness scores between the least core-like +member of the core and the most core-like member of the periphery. +"diff" is similar to "ndiff", but multiplies the raw "ndiff" score by the +square root of the size of the core, thus penalising large cores. +} + \section{Core-Periphery}{ \code{net_by_core()} calculates the Pearson correlation between the given diff --git a/man/member_cliques.Rd b/man/member_cliques.Rd index 01fc59b..01563b9 100644 --- a/man/member_cliques.Rd +++ b/man/member_cliques.Rd @@ -5,14 +5,14 @@ \alias{node_in_roulette} \title{Memberships in maximally diverse cliques} \usage{ -node_in_roulette(.data, num_groups, group_size, times = NULL) +node_in_roulette(.data, groups, group_size, times = NULL, num_groups = NULL) } \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{num_groups}{An integer indicating the number of groups desired.} +\item{groups}{An integer indicating the number of groups desired.} \item{group_size}{An integer indicating the desired size of most of the groups. Note that if the number of nodes is not divisible into groups of equal size, @@ -24,6 +24,9 @@ and the best or the most frequent result is kept. Where the algorithm searches, this is how many steps the search takes. More repetitions give a more reliable result and take longer, so each function documents its own default.} + +\item{num_groups}{Deprecated. The former spelling of \code{groups}. +Still accepted, but warns; please use \code{groups} instead.} } \value{ A \code{node_member} character vector the length of the nodes in the network, @@ -72,7 +75,7 @@ The user is referred to Lai and Hao (2016) and Lai et al (2021) for more details } \examples{ -node_in_roulette(ison_adolescents, num_groups = 3) +node_in_roulette(ison_adolescents, groups = 3) } \references{ \subsection{On the maximally diverse grouping problem}{ diff --git a/man/member_community.Rd b/man/member_community.Rd index 4c42b94..4b3b00d 100644 --- a/man/member_community.Rd +++ b/man/member_community.Rd @@ -5,7 +5,14 @@ \alias{node_in_community} \title{Memberships in communities} \usage{ -node_in_community(.data, k = NULL, Kmax = 8L, consensus = FALSE, times = 20) +node_in_community( + .data, + k = NULL, + max_k = 8L, + consensus = FALSE, + times = 20, + Kmax = NULL +) } \arguments{ \item{.data}{A network object of class \code{stocnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. @@ -25,12 +32,12 @@ coverage curve has no clear elbow. If the algorithm cannot return exactly the number of communities requested, a warning is given and the nearest number is returned.} -\item{Kmax}{Integer indicating the maximum number of communities to +\item{max_k}{Integer indicating the maximum number of communities to evaluate for \code{"silhouette"} and \code{"elbow"}. By default \code{8}. Otherwise ignored. Note that for \code{node_in_louvain()} and \code{node_in_leiden()} each candidate requires its own search over the resolution parameter, -so a large \code{Kmax} is costly on large networks.} +so a large \code{max_k} is costly on large networks.} \item{consensus}{Logical, whether to combine the partitions of all the applicable algorithms instead of selecting the one with the highest @@ -45,6 +52,9 @@ and the best or the most frequent result is kept. Where the algorithm searches, this is how many steps the search takes. More repetitions give a more reliable result and take longer, so each function documents its own default.} + +\item{Kmax}{Deprecated. The former spelling of \code{max_k}. +Still accepted, but warns; please use \code{max_k} instead.} } \value{ A \code{node_member} character vector the length of the nodes in the network, diff --git a/man/member_community_hier.Rd b/man/member_community_hier.Rd index 1d76151..12a46f5 100644 --- a/man/member_community_hier.Rd +++ b/man/member_community_hier.Rd @@ -8,13 +8,13 @@ \alias{node_in_walktrap} \title{Memberships in hierarchical communities} \usage{ -node_in_betweenness(.data, k = NULL, Kmax = 8L) +node_in_betweenness(.data, k = NULL, max_k = 8L, Kmax = NULL) -node_in_greedy(.data, k = NULL, Kmax = 8L) +node_in_greedy(.data, k = NULL, max_k = 8L, Kmax = NULL) -node_in_eigen(.data, k = NULL, Kmax = 8L) +node_in_eigen(.data, k = NULL, max_k = 8L, Kmax = NULL) -node_in_walktrap(.data, k = NULL, Kmax = 8L, steps = 4) +node_in_walktrap(.data, k = NULL, max_k = 8L, steps = 4, Kmax = NULL) } \arguments{ \item{.data}{A network object of class \code{stocnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. @@ -34,12 +34,15 @@ coverage curve has no clear elbow. If the algorithm cannot return exactly the number of communities requested, a warning is given and the nearest number is returned.} -\item{Kmax}{Integer indicating the maximum number of communities to +\item{max_k}{Integer indicating the maximum number of communities to evaluate for \code{"silhouette"} and \code{"elbow"}. By default \code{8}. Otherwise ignored. Note that for \code{node_in_louvain()} and \code{node_in_leiden()} each candidate requires its own search over the resolution parameter, -so a large \code{Kmax} is costly on large networks.} +so a large \code{max_k} is costly on large networks.} + +\item{Kmax}{Deprecated. The former spelling of \code{max_k}. +Still accepted, but warns; please use \code{max_k} instead.} \item{steps}{Integer indicating the length of the random walks. By default \code{steps = 4}, as in \code{{igraph}}. diff --git a/man/member_community_non.Rd b/man/member_community_non.Rd index 0fc8255..cee4e02 100644 --- a/man/member_community_non.Rd +++ b/man/member_community_non.Rd @@ -14,19 +14,19 @@ \usage{ node_in_optimal(.data) -node_in_partition(.data, k = 2L, Kmax = 8L) +node_in_partition(.data, k = 2L, max_k = 8L, Kmax = NULL) node_in_infomap(.data, times = 50) node_in_spinglass(.data, max_k = 200, resolution = 1) -node_in_fluid(.data, k = NULL, Kmax = 8L) +node_in_fluid(.data, k = NULL, max_k = 8L, Kmax = NULL) -node_in_louvain(.data, k = NULL, Kmax = 8L, resolution = 1) +node_in_louvain(.data, k = NULL, max_k = 8L, resolution = 1, Kmax = NULL) -node_in_leiden(.data, k = NULL, Kmax = 8L, resolution = 1) +node_in_leiden(.data, k = NULL, max_k = 8L, resolution = 1, Kmax = NULL) -node_in_labels(.data, k = NULL, Kmax = 8L) +node_in_labels(.data, k = NULL, max_k = 8L, Kmax = NULL) } \arguments{ \item{.data}{A network object of class \code{stocnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. @@ -46,12 +46,15 @@ coverage curve has no clear elbow. If the algorithm cannot return exactly the number of communities requested, a warning is given and the nearest number is returned.} -\item{Kmax}{Integer indicating the maximum number of communities to +\item{max_k}{Integer indicating the maximum number of communities to evaluate for \code{"silhouette"} and \code{"elbow"}. By default \code{8}. Otherwise ignored. Note that for \code{node_in_louvain()} and \code{node_in_leiden()} each candidate requires its own search over the resolution parameter, -so a large \code{Kmax} is costly on large networks.} +so a large \code{max_k} is costly on large networks.} + +\item{Kmax}{Deprecated. The former spelling of \code{max_k}. +Still accepted, but warns; please use \code{max_k} instead.} \item{times}{Integer scalar, how many times the algorithm repeats its work. Where the algorithm is stochastic, this is how many times it runs, @@ -60,9 +63,6 @@ Where the algorithm searches, this is how many steps the search takes. More repetitions give a more reliable result and take longer, so each function documents its own default.} -\item{max_k}{Integer constant, the number of spins to use as an upper limit -of communities to be found. Some sets can be empty at the end.} - \item{resolution}{The Reichardt-Bornholdt “gamma” resolution parameter for modularity. By default 1, making existing and non-existing ties equally important. Smaller values make existing ties more important, @@ -132,6 +132,9 @@ per node required to encode the path. \section{Spin-glass}{ +Here \code{max_k} is the number of spins, an upper limit on the communities +found rather than a bound on a search, so some can end up empty. + This is motivated by analogy to the Potts model in statistical physics. Each node can be in one of \emph{k} "spin states", and ties (particle interactions) provide information about which pairs of nodes diff --git a/man/member_equivalence.Rd b/man/member_equivalence.Rd index fb23b94..bd28752 100644 --- a/man/member_equivalence.Rd +++ b/man/member_equivalence.Rd @@ -19,7 +19,8 @@ node_in_equivalence( k = c("silhouette", "elbow", "strict"), cluster = c("hierarchical", "concor", "cosine"), distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), - Kmax = 8L + max_k = 8L, + Kmax = NULL ) node_in_structural( @@ -27,7 +28,8 @@ node_in_structural( k = c("silhouette", "elbow", "strict"), cluster = c("hierarchical", "concor", "cosine"), distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), - Kmax = 8L + max_k = 8L, + Kmax = NULL ) node_in_regular( @@ -35,10 +37,11 @@ node_in_regular( k = c("silhouette", "elbow", "strict"), cluster = c("hierarchical", "concor", "cosine"), distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), - Kmax = 8L, + max_k = 8L, regularity = c("rolesim", "rege"), decay = 0.15, - beta = NULL + beta = NULL, + Kmax = NULL ) node_in_motif( @@ -46,7 +49,8 @@ node_in_motif( k = c("silhouette", "elbow", "strict"), cluster = c("hierarchical", "concor", "cosine"), distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), - Kmax = 8L + max_k = 8L, + Kmax = NULL ) node_in_automorphic( @@ -54,7 +58,8 @@ node_in_automorphic( k = c("silhouette", "elbow", "strict"), cluster = c("hierarchical", "concor", "cosine"), distance = c("euclidean", "maximum", "manhattan", "canberra", "binary", "minkowski"), - Kmax = 8L + max_k = 8L, + Kmax = NULL ) node_in_block(.data, k = 2L, blocks = c("nul", "com"), times = NULL) @@ -87,10 +92,13 @@ By default \code{"euclidean"}, but other options include \code{"maximum"}, \code{"manhattan"}, \code{"canberra"}, \code{"binary"}, and \code{"minkowski"}. Fewer, identifiable letters, e.g. \code{"e"} for Euclidean, is sufficient.} -\item{Kmax}{Integer indicating the maximum number of (k) clusters +\item{max_k}{Integer indicating the maximum number of (k) clusters to evaluate. Ignored when \code{k = "strict"} or a discrete number is given for \code{k}.} +\item{Kmax}{Deprecated. The former spelling of \code{max_k}. +Still accepted, but warns; please use \code{max_k} instead.} + \item{regularity}{Character string indicating which algorithm should be used to calculate how regularly equivalent nodes are. By default \code{"rolesim"}; \code{"rege"} is also available. diff --git a/man/method_kselect.Rd b/man/method_kselect.Rd index 7a75473..096d339 100644 --- a/man/method_kselect.Rd +++ b/man/method_kselect.Rd @@ -10,11 +10,11 @@ \usage{ k_strict(hc, .data) -k_elbow(hc, .data, motif, Kmax) +k_elbow(hc, .data, motif, max_k) -k_silhouette(hc, .data, Kmax) +k_silhouette(hc, .data, max_k) -k_gap(hc, motif, Kmax, sims = 100) +k_gap(hc, motif, max_k, sims = 100) } \arguments{ \item{hc}{A hierarchical clustering object.} @@ -25,7 +25,7 @@ For more information on possible coercions, see e.g. \code{\link[manynet:as_stoc \item{motif}{A motif census object.} -\item{Kmax}{An integer indicating the maximum number of options to consider. +\item{max_k}{An integer indicating the maximum number of options to consider. The minimum of this and the number of nodes in the network is used.} \item{sims}{Integer of how many simulations should be generated as a diff --git a/man/motif_brokerage_net.Rd b/man/motif_brokerage_net.Rd index 6466fc1..3766c18 100644 --- a/man/motif_brokerage_net.Rd +++ b/man/motif_brokerage_net.Rd @@ -19,9 +19,13 @@ the corresponding node. While this may often be a vector created using \verb{node_in_*()} functions, it can be any character vector that assigns nodes to groups or categories.} -\item{standardized}{Whether the score should be standardized -into a \emph{z}-score indicating how many standard deviations above -or below the average the score lies.} +\item{standardized}{Logical scalar. Where \code{TRUE}, the counts are returned +as z-scores against a null model rather than as raw counts. +This is a different quantity from \code{normalized}, which divides by a +theoretical maximum, and from \code{scaled}, which divides by the observed +maximum: a z-score says how far the count departs from what the null +model expects, so it can be negative and has no fixed range. +By default \code{FALSE}.} } \value{ A \code{network_motif} named numeric vector or sometimes a data frame with diff --git a/man/motif_brokerage_node.Rd b/man/motif_brokerage_node.Rd index fe2ee84..a5f5b89 100644 --- a/man/motif_brokerage_node.Rd +++ b/man/motif_brokerage_node.Rd @@ -19,9 +19,13 @@ the corresponding node. While this may often be a vector created using \verb{node_in_*()} functions, it can be any character vector that assigns nodes to groups or categories.} -\item{standardized}{Whether the score should be standardized -into a \emph{z}-score indicating how many standard deviations above -or below the average the score lies.} +\item{standardized}{Logical scalar. Where \code{TRUE}, the counts are returned +as z-scores against a null model rather than as raw counts. +This is a different quantity from \code{normalized}, which divides by a +theoretical maximum, and from \code{scaled}, which divides by the observed +maximum: a z-score says how far the count departs from what the null +model expects, so it can be negative and has no fixed range. +By default \code{FALSE}.} } \value{ A \code{node_motif} matrix with one row for each node in the network and diff --git a/tests/testthat/helper-contract.R b/tests/testthat/helper-contract.R index 81fb0b8..cf6205f 100644 --- a/tests/testthat/helper-contract.R +++ b/tests/testthat/helper-contract.R @@ -340,16 +340,17 @@ check_measure_contract <- function(roster, .data, note_gap(fn, "accepts a `decay` above 1") } - # Every choice of `method` should run, and should say which one ran, so - # that a result carrying no `variant` cannot be traced back to its method. - if ("method" %in% names(fargs)) { - for (m in eval(fargs$method)) { - alt <- try(call_measure(fn, c(roster[[fn]], list(method = m)), .data), - silent = TRUE) + # Every choice a measure offers should run, and should say which one ran, + # so that a result carrying no `variant` cannot be traced back to its + # choice. `method` was split into these narrower names, so both are swept. + for (arg in intersect(c("variant", "walks"), names(fargs))) { + for (m in eval(fargs[[arg]])) { + alt <- try(call_measure(fn, c(roster[[fn]], stats::setNames(list(m), arg)), + .data), silent = TRUE) if (inherits(alt, "try-error")) { - note_gap(fn, sprintf("errors when `method = \"%s\"`", m)) + note_gap(fn, sprintf("errors when `%s = \"%s\"`", arg, m)) } else if (is.null(attr(alt, "variant"))) { - note_gap(fn, sprintf("declares no `variant` for `method = \"%s\"`", m)) + note_gap(fn, sprintf("declares no `variant` for `%s = \"%s\"`", arg, m)) } } } diff --git a/tests/testthat/test-measure_centrality_contract.R b/tests/testthat/test-measure_centrality_contract.R index a17c30e..4c4b9f2 100644 --- a/tests/testthat/test-measure_centrality_contract.R +++ b/tests/testthat/test-measure_centrality_contract.R @@ -117,11 +117,11 @@ test_that("subgraph centrality splits its walks as documented", { # so replacing that call with an eigendecomposition changed no results. expect_equal(all, as.numeric(igraph::subgraph_centrality(manynet::as_igraph(g)))) # Odd- and even-length closed walks partition the whole count. - expect_equal(as.numeric(node_by_subgraph(g, method = "odd")) + - as.numeric(node_by_subgraph(g, method = "even")), all) + expect_equal(as.numeric(node_by_subgraph(g, walks = "odd")) + + as.numeric(node_by_subgraph(g, walks = "even")), all) # Each variant says which one it is. - expect_equal(attr(node_by_subgraph(g, method = "odd"), "variant"), "odd") - expect_equal(attr(node_by_subgraph(g, method = "odd"), "measure"), + expect_equal(attr(node_by_subgraph(g, walks = "odd"), "variant"), "odd") + expect_equal(attr(node_by_subgraph(g, walks = "odd"), "measure"), "odd subgraph centrality") # Discounting longer walks changes the scores but not their positivity. expect_false(isTRUE(all.equal(as.numeric(node_by_subgraph(g, decay = 0.5)), all))) @@ -136,7 +136,7 @@ test_that("bipartivity recognises a two-mode network", { expect_true(bip > 0 && bip < 1) # Bipartivity is the network-level share of what node_by_subgraph() splits. expect_equal(bip, - sum(node_by_subgraph(manynet::ison_adolescents, method = "even")) / + sum(node_by_subgraph(manynet::ison_adolescents, walks = "even")) / sum(node_by_subgraph(manynet::ison_adolescents))) }) diff --git a/tests/testthat/test-measure_closure_contract.R b/tests/testthat/test-measure_closure_contract.R index 28edf95..78c07fa 100644 --- a/tests/testthat/test-measure_closure_contract.R +++ b/tests/testthat/test-measure_closure_contract.R @@ -26,15 +26,15 @@ test_that("reciprocity records which of its two methods ran", { # so the variant is what distinguishes the results rather than the range. nw <- manynet::ison_networkers expect_equal(attr(net_by_reciprocity(nw), "variant"), "default") - expect_equal(attr(net_by_reciprocity(nw, method = "ratio"), "variant"), "ratio") - expect_equal(attr(net_by_reciprocity(nw, method = "ratio"), "normalization"), + expect_equal(attr(net_by_reciprocity(nw, variant = "ratio"), "variant"), "ratio") + expect_equal(attr(net_by_reciprocity(nw, variant = "ratio"), "normalization"), "normalized") # A variant that says nothing about the values would be decorative; these # two genuinely differ. expect_false(isTRUE(all.equal(as.numeric(net_by_reciprocity(nw)), - as.numeric(net_by_reciprocity(nw, method = "ratio"))))) + as.numeric(net_by_reciprocity(nw, variant = "ratio"))))) # Unrecognised methods are now caught here rather than passed to igraph. - expect_error(net_by_reciprocity(nw, method = "nonsense")) + expect_error(net_by_reciprocity(nw, variant = "nonsense")) }) test_that("congruency meets the measure contract", { diff --git a/tests/testthat/test-measure_features_contract.R b/tests/testthat/test-measure_features_contract.R index aaea307..6ec6d9a 100644 --- a/tests/testthat/test-measure_features_contract.R +++ b/tests/testthat/test-measure_features_contract.R @@ -21,23 +21,23 @@ test_that("measures on several scales report which one they are on", { # differences, so a single range would be wrong for three of the four. g <- manynet::ison_adolescents expect_equal(attr(net_by_core(g), "range"), c(-1, 1)) - expect_equal(attr(net_by_core(g, method = "ident"), "measure"), + expect_equal(attr(net_by_core(g, variant = "ident"), "measure"), "core-periphery distance") - expect_equal(attr(net_by_core(g, method = "ident"), "range"), c(0, Inf)) + expect_equal(attr(net_by_core(g, variant = "ident"), "range"), c(0, Inf)) # Sigma is a ratio of ratios with no upper bound; omega and SWI are bounded. - expect_equal(attr(net_by_smallworld(g, method = "sigma", times = 20), "range"), + expect_equal(attr(net_by_smallworld(g, variant = "sigma", times = 20), "range"), c(0, Inf)) # Which of the three coefficients ran is recorded as a variant rather than # spelled into the measure name, so the measure stays the same across them. expect_equal(attr(net_by_smallworld(g, times = 20), "measure"), "small-world coefficient") expect_equal(attr(net_by_smallworld(g, times = 20), "variant"), "omega") - expect_equal(attr(net_by_smallworld(g, method = "SWI", times = 20), "variant"), + expect_equal(attr(net_by_smallworld(g, variant = "SWI", times = 20), "variant"), "SWI") # A variant is orthogonal to a normalisation: SWI is both. - expect_equal(attr(net_by_smallworld(g, method = "SWI", times = 20), + expect_equal(attr(net_by_smallworld(g, variant = "SWI", times = 20), "normalization"), "normalized") - expect_equal(attr(net_by_core(g, method = "ident"), "variant"), "ident") + expect_equal(attr(net_by_core(g, variant = "ident"), "variant"), "ident") # The modularity floor moves with the resolution, so the range follows it. memb <- node_in_partition(g) expect_equal(attr(net_by_modularity(g, memb), "range"), c(-0.5, 1)) diff --git a/tests/testthat/test-measure_fit.R b/tests/testthat/test-measure_fit.R index f587ac6..bf6eb50 100644 --- a/tests/testthat/test-measure_fit.R +++ b/tests/testthat/test-measure_fit.R @@ -7,8 +7,8 @@ test_that("net_modularity works for two mode networks", { test_that("net_core works", { out <- net_by_core(ison_adolescents) expect_values(out, -0.133) - expect_values(net_by_core(ison_adolescents, method = "ident"), 6.481) - expect_values(net_by_core(ison_adolescents, method = "diff"), 5.619) + expect_values(net_by_core(ison_adolescents, variant = "ident"), 6.481) + expect_values(net_by_core(ison_adolescents, variant = "diff"), 5.619) }) test_that("net_by_inconsistency scores a partition against ideal blocks", { diff --git a/tests/testthat/test-member_cliques.R b/tests/testthat/test-member_cliques.R index 2513769..f4666ad 100644 --- a/tests/testthat/test-member_cliques.R +++ b/tests/testthat/test-member_cliques.R @@ -1,5 +1,5 @@ test_that("node_in_roulette works", { - res <- node_in_roulette(ison_adolescents, num_groups = 3) + res <- node_in_roulette(ison_adolescents, groups = 3) expect_s3_class(res, "node_member") expect_length(res, net_nodes(ison_adolescents)) expect_false(res[1] == res[2]) diff --git a/tests/testthat/test-member_nodes.R b/tests/testthat/test-member_nodes.R index faece8a..a97e037 100644 --- a/tests/testthat/test-member_nodes.R +++ b/tests/testthat/test-member_nodes.R @@ -9,7 +9,7 @@ for(fn in names(node_membs)) { !igraph::is_connected(manynet::as_igraph(data_objs[[ob]]))) if(grepl("roulette", fn)){ if(ob != "twomode") - expect_s3_class(node_membs[[fn]](data_objs[[ob]], num_groups = 3), + expect_s3_class(node_membs[[fn]](data_objs[[ob]], groups = 3), "node_member") else succeed("Roulette doesn't work on two-mode objects") } else if(grepl("adopter", fn)){ From 15782e963ecd6149858493fed8365bc1b04d0522 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 14:31:04 +0200 Subject: [PATCH 64/68] Added `split_bins()`, `split_quantiles()` and `split_kmeans()` --- .github/CONTRIBUTING.md | 7 ++++ NAMESPACE | 3 ++ NEWS.md | 2 + R/class_metrics.R | 14 +++++++ R/method_split.R | 63 +++++++++++++++++++++++++++++++ man/member_core.Rd | 17 ++++++--- man/method_split.Rd | 63 +++++++++++++++++++++++++++++++ tests/testthat/test-member_core.R | 2 +- 8 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 R/method_split.R create mode 100644 man/method_split.Rd diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 71aaf49..e122a9e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -151,6 +151,7 @@ Users can therefore find the implementation, and its documentation, from the arg | `method_cluster` | an `hclust` clustering object | `cluster_*` | `cluster =` | | `method_regularity` | a node-by-node similarity matrix | `regularity_*` | `regularity =` | | `method_coreness` | a continuous coreness score plus a core/periphery split | `coreness_*` | `coreness =` | +| `method_split` | an ordered split of a continuous score into groups | `split_*` | `split =` | Apply that test when naming a new family. For example, `equivalence_*` would be the wrong name for `regularity_*`, even though those methods are only ever called from `node_in_regular()`: they return a *similarity*, which `cluster_*()` only later partitions into an equivalence. Naming the step for the pipeline's eventual output rather than its own return value breaks the rule. @@ -435,6 +436,12 @@ Run `devtools::document()` after changing any roxygen comment. examples are run by R CMD check, and they are also the fastest documentation for users. Prefer the bundled `ison_*`/`fict_*` networks over ad hoc constructions, unless they take too long to run. + Two exemptions, both deliberate: + the `method_*` topics that document `cluster_*()`, `k_*()` and `regularity_*()` + carry none, because users reach those through an argument rather than by + calling them; and where no fast example exists, the topic goes without one + rather than carrying a slow one, since a CRAN check that times out costs more + than the example gains. Do not reach for `\donttest{}` to keep a slow example. - Cite the source of a measure with `@references` in the ecosystem's format (authors, year, title, journal, and `\doi{}` where available), so that users can trace an implementation back to its definition. diff --git a/NAMESPACE b/NAMESPACE index b0e27dd..ed17676 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -187,6 +187,9 @@ export(node_x_ties) export(node_x_triad) export(regularity_rege) export(regularity_rolesim) +export(split_bins) +export(split_kmeans) +export(split_quantiles) export(tie_by_betweenness) export(tie_by_closeness) export(tie_by_cohesion) diff --git a/NEWS.md b/NEWS.md index 57fc4bf..6f8c0b5 100644 --- a/NEWS.md +++ b/NEWS.md @@ -162,6 +162,8 @@ - `coreness_rich()` is Ma and Mondragon's rich-core for directed and two-mode networks - `coreness_hub()` is Elliott and colleagues' more granualr directed core-periphery - `coreness_transition()` is Rombach and colleagues' core score over boundary sharpness and core size +- Added `split_bins()`, `split_quantiles()` and `split_kmeans()` + - Each splits a continuous score into an ordered set of groups ## Tutorials diff --git a/R/class_metrics.R b/R/class_metrics.R index 08af34f..b18ae60 100644 --- a/R/class_metrics.R +++ b/R/class_metrics.R @@ -113,6 +113,20 @@ resolve_method <- function(new, method = NULL, new_name) { new } +# `cluster_by` selected a method by `switch()` but named no helper, and read +# as a variant of `cluster=`, which selects the `cluster_*()` hclust helpers +# and is a different thing. The methods are now `split_*()`, named for what +# they return. Accepts the old spelling and warns. +resolve_split <- function(split, cluster_by = NULL) { + if(!is.null(cluster_by)) { + warning("The `cluster_by` argument has been renamed `split`, ", + "which names the `split_*()` methods it chooses between. ", + "Please use `split` instead.", call. = FALSE) + split <- cluster_by + } + split +} + # Several measures discount a contribution once per step of distance or walk # length. The literature names that discount differently in each case — # Bonacich and Lloyd's alpha, RoleSim's beta, PageRank's damping factor, diff --git a/R/method_split.R b/R/method_split.R new file mode 100644 index 0000000..1229843 --- /dev/null +++ b/R/method_split.R @@ -0,0 +1,63 @@ +#' Methods for splitting a continuous score into ordered groups +#' +#' @description +#' These functions split a continuous score, such as a coreness score, +#' into an ordered set of groups: +#' +#' - `split_bins()` cuts the range into equal-width bins. +#' - `split_quantiles()` cuts at the quantiles, so each group holds a +#' similar number of nodes. +#' - `split_kmeans()` clusters the scores by k-means, so the cuts fall +#' where the scores themselves are furthest apart. +#' +#' These functions are not intended to be called directly, +#' but are called within `node_in_core()` and related functions. +#' They are exported and listed here to provide more detailed documentation. +#' @name method_split +#' @param scores A numeric vector of scores to split. +#' @param groups An integer indicating the number of groups to split into. +#' @returns +#' An integer vector the length of `scores`, +#' giving each score's group index, numbered from the lowest score upwards. +NULL + +#' @rdname method_split +#' @section Bins: +#' Cuts the observed range into `groups` intervals of equal width. +#' Where the scores are unevenly spread, a bin can end up empty, +#' so this returns the coarsest picture of the three. +#' @examples +#' split_bins(c(0, 0.1, 0.4, 0.9, 1), 3) +#' @export +split_bins <- function(scores, groups){ + cut(scores, breaks = groups, labels = FALSE) +} + +#' @rdname method_split +#' @section Quantiles: +#' Cuts at the quantiles of the scores, so each group holds a similar +#' number of nodes whatever the shape of the distribution. +#' @examples +#' split_quantiles(c(0, 0.1, 0.4, 0.9, 1), 3) +#' @export +split_quantiles <- function(scores, groups){ + as.numeric(cut(scores, + breaks = stats::quantile(scores, + probs = seq(0, 1, + length.out = groups + 1)), + include.lowest = TRUE, labels = FALSE)) +} + +#' @rdname method_split +#' @section K-means: +#' Clusters the scores by k-means, so the cuts fall where the scores are +#' furthest apart rather than at fixed widths or counts. +#' @examples +#' split_kmeans(c(0, 0.1, 0.4, 0.9, 1), 3) +#' @export +split_kmeans <- function(scores, groups){ + km <- stats::kmeans(scores, centers = groups) + # k-means numbers its clusters in whatever order it finds them, so the + # numbers must be put back in score order before they can index the labels. + order(order(km$centers))[km$cluster] +} diff --git a/man/member_core.Rd b/man/member_core.Rd index 7f70916..1455ac8 100644 --- a/man/member_core.Rd +++ b/man/member_core.Rd @@ -8,9 +8,10 @@ node_in_core( .data, groups = 3, - cluster_by = c("bins", "quantiles", "kmeans"), + split = c("bins", "quantiles", "kmeans"), coreness = NULL, - direction = c("all", "out", "in", "both") + direction = c("all", "out", "in", "both"), + cluster_by = NULL ) } \arguments{ @@ -21,9 +22,10 @@ For more information on possible coercions, see e.g. \code{\link[manynet:as_stoc \item{groups}{Number of categories to create. Must be at least 2 and at most the number of nodes in the network. Default is 3.} -\item{cluster_by}{Method to use to create the categories. -One of "bins" (equal-width bins), "quantiles" (quantile-based bins), -or "kmeans" (k-means clustering). Default is "bins".} +\item{split}{Which method to use to split the coreness scores into the +categories. One of "bins" (equal-width bins), "quantiles" +(quantile-based bins), or "kmeans" (k-means clustering); +see \link{method_split} for what each does. Default is "bins".} \item{coreness}{Which method to use to calculate nodes' coreness. One of "correlation", "rich", "transition", or "hub"; @@ -36,6 +38,9 @@ directly, and "correlation" otherwise.} For a directed network, "out" scores nodes on the ties they send and "in" on the ties they receive, while "both" returns the four categories described below. Ignored for undirected and two-mode networks.} + +\item{cluster_by}{Deprecated. The former spelling of \code{split}. +Still accepted, but warns; please use \code{split} instead.} } \value{ A \code{node_member} character vector the length of the nodes in the network, @@ -70,7 +75,7 @@ Elliott and colleagues distinguish: \item "Periphery" for nodes in neither. } -This uses \code{\link[=coreness_hub]{coreness_hub()}}, so \code{groups} and \code{cluster_by} do not apply. +This uses \code{\link[=coreness_hub]{coreness_hub()}}, so \code{groups} and \code{split} do not apply. } \examples{ diff --git a/man/method_split.Rd b/man/method_split.Rd new file mode 100644 index 0000000..70b446c --- /dev/null +++ b/man/method_split.Rd @@ -0,0 +1,63 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/method_split.R +\name{method_split} +\alias{method_split} +\alias{split_bins} +\alias{split_quantiles} +\alias{split_kmeans} +\title{Methods for splitting a continuous score into ordered groups} +\usage{ +split_bins(scores, groups) + +split_quantiles(scores, groups) + +split_kmeans(scores, groups) +} +\arguments{ +\item{scores}{A numeric vector of scores to split.} + +\item{groups}{An integer indicating the number of groups to split into.} +} +\value{ +An integer vector the length of \code{scores}, +giving each score's group index, numbered from the lowest score upwards. +} +\description{ +These functions split a continuous score, such as a coreness score, +into an ordered set of groups: +\itemize{ +\item \code{split_bins()} cuts the range into equal-width bins. +\item \code{split_quantiles()} cuts at the quantiles, so each group holds a +similar number of nodes. +\item \code{split_kmeans()} clusters the scores by k-means, so the cuts fall +where the scores themselves are furthest apart. +} + +These functions are not intended to be called directly, +but are called within \code{node_in_core()} and related functions. +They are exported and listed here to provide more detailed documentation. +} +\section{Bins}{ + +Cuts the observed range into \code{groups} intervals of equal width. +Where the scores are unevenly spread, a bin can end up empty, +so this returns the coarsest picture of the three. +} + +\section{Quantiles}{ + +Cuts at the quantiles of the scores, so each group holds a similar +number of nodes whatever the shape of the distribution. +} + +\section{K-means}{ + +Clusters the scores by k-means, so the cuts fall where the scores are +furthest apart rather than at fixed widths or counts. +} + +\examples{ +split_bins(c(0, 0.1, 0.4, 0.9, 1), 3) +split_quantiles(c(0, 0.1, 0.4, 0.9, 1), 3) +split_kmeans(c(0, 0.1, 0.4, 0.9, 1), 3) +} diff --git a/tests/testthat/test-member_core.R b/tests/testthat/test-member_core.R index 5c47ca2..8bae8c7 100644 --- a/tests/testthat/test-member_core.R +++ b/tests/testthat/test-member_core.R @@ -17,7 +17,7 @@ test_that("node_in_core labels the most and least core node correctly", { # the wrong nodes entirely. cn <- as.numeric(node_by_core(ison_adolescents)) for (cb in c("bins", "quantiles", "kmeans")) { - lab <- as.character(node_in_core(ison_adolescents, cluster_by = cb)) + lab <- as.character(node_in_core(ison_adolescents, split = cb)) expect_equal(lab[which.max(cn)], "Core", info = cb) expect_equal(lab[which.min(cn)], "Periphery", info = cb) } From 4f733a5f91488d06972243444da081b6760fc81e Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 14:49:38 +0200 Subject: [PATCH 65/68] Fixed `net_by_waves()` reporting one wave where waves are held as `time` --- NEWS.md | 4 +++- R/measure_change.R | 5 ++++- R/member_equivalence.R | 26 ++++++++++++++++++++-- R/netrics-utils.R | 16 ++++++++++++++ man/member_equivalence.Rd | 30 ++++++++++++++++++++++++-- tests/testthat/test-measure_features.R | 9 ++++++++ 6 files changed, 84 insertions(+), 6 deletions(-) diff --git a/NEWS.md b/NEWS.md index 6f8c0b5..cce69c8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -93,6 +93,7 @@ - Fixed `node_by_reciprocity()` to return 1 throughout for any undirected network - Fixed `node_by_information()` on rectangular matrices by using `manynet::to_multilevel()` - Fixed `net_by_independence()` erroring on multilevel networks by measuring whole +- Fixed `net_by_waves()` reporting one wave where waves are held as `time` ## Memberships @@ -129,7 +130,8 @@ - Fixed `node_in_regular()` to compute regular equivalence correctly - Choose between `regularity = "rolesim"` (default) and `"rege"` for recursive similarity - Note existing scripts calling `node_in_regular()` will now return more correct results - - Moved former similarity of local embedding to `node_in_motif()` + - Moved counting of motif types to `node_in_motif()`, + though neither is Burt's equivalence or an orbit-aware census (thanks @Kaladani) - Renamed `Kmax=` to `max_k=` in the community and equivalence functions - Renamed `num_groups=` to `groups=` in `node_in_roulette()` - Renamed `cluster_by=` to `split=` in `node_in_core()` diff --git a/R/measure_change.R b/R/measure_change.R index e60f093..2de8d25 100644 --- a/R/measure_change.R +++ b/R/measure_change.R @@ -16,7 +16,10 @@ NULL #' @export net_by_waves <- function(.data){ .data <- manynet::expect_nodes(.data) - tie_waves <- length(unique(manynet::tie_attribute(.data, "wave"))) + # 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) 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/member_equivalence.R b/R/member_equivalence.R index 715a2a4..1e5638d 100644 --- a/R/member_equivalence.R +++ b/R/member_equivalence.R @@ -163,12 +163,34 @@ node_in_regular <- function(.data, #' in, by clustering a census of the triads (or, for two-mode networks, #' tetrads) each node participates in. #' -#' This captures similarity of local embedding rather than equivalence of -#' role. It is well suited to distinguishing nodes that sit in dense, +#' Note that the census counts the _types_ of motif a node takes part in, +#' and not the position it holds within them. +#' In the path \eqn{i \rightarrow k \rightarrow j}, for example, +#' all three nodes return a profile of one 021C triad, +#' although \eqn{i} sends, \eqn{k} mediates and \eqn{j} receives. +#' This is therefore neither Burt's role equivalence, +#' which distinguishes those positions, +#' nor the orbit-aware census of Ortmann and Brandes, +#' which netrics does not yet offer. +#' +#' What it captures is similarity of local embedding. +#' It is well suited to distinguishing nodes that sit in dense, #' closed neighbourhoods from those that bridge open ones, #' but it is not regular equivalence: see `node_in_regular()` for that. #' #' This function was called `node_in_regular()` prior to version 1.0.0. +#' @references +#' ## On role equivalence +#' Burt, Ronald S. 1990. +#' "Detecting role equivalence". +#' _Social Networks_ 12(1): 83-97. +#' \doi{10.1016/0378-8733(90)90023-3} +#' +#' ## On the orbit-aware census +#' Ortmann, Mark, and Ulrik Brandes. 2017. +#' "Efficient orbit-aware triad and quad census in directed and undirected graphs". +#' _Applied Network Science_ 2(1): 13. +#' \doi{10.1007/s41109-017-0027-2} #' @examples #' (nme <- node_in_motif(ison_southern_women, cluster = "concor")) #' @export diff --git a/R/netrics-utils.R b/R/netrics-utils.R index b9e835d..d209ac8 100644 --- a/R/netrics-utils.R +++ b/R/netrics-utils.R @@ -51,6 +51,22 @@ seq_nodes <- function(.data){ 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. diff --git a/man/member_equivalence.Rd b/man/member_equivalence.Rd index bd28752..b44efb6 100644 --- a/man/member_equivalence.Rd +++ b/man/member_equivalence.Rd @@ -178,8 +178,18 @@ Where the other functions here compare nodes on \emph{whom} they are tied to, in, by clustering a census of the triads (or, for two-mode networks, tetrads) each node participates in. -This captures similarity of local embedding rather than equivalence of -role. It is well suited to distinguishing nodes that sit in dense, +Note that the census counts the \emph{types} of motif a node takes part in, +and not the position it holds within them. +In the path \eqn{i \rightarrow k \rightarrow j}, for example, +all three nodes return a profile of one 021C triad, +although \eqn{i} sends, \eqn{k} mediates and \eqn{j} receives. +This is therefore neither Burt's role equivalence, +which distinguishes those positions, +nor the orbit-aware census of Ortmann and Brandes, +which netrics does not yet offer. + +What it captures is similarity of local embedding. +It is well suited to distinguishing nodes that sit in dense, closed neighbourhoods from those that bridge open ones, but it is not regular equivalence: see \code{node_in_regular()} for that. @@ -218,6 +228,22 @@ if(require("sna", quietly = TRUE)){ net_by_inconsistency(ison_adolescents, nbm) } \references{ +\subsection{On role equivalence}{ + +Burt, Ronald S. 1990. +"Detecting role equivalence". +\emph{Social Networks} 12(1): 83-97. +\doi{10.1016/0378-8733(90)90023-3} +} + +\subsection{On the orbit-aware census}{ + +Ortmann, Mark, and Ulrik Brandes. 2017. +"Efficient orbit-aware triad and quad census in directed and undirected graphs". +\emph{Applied Network Science} 2(1): 13. +\doi{10.1007/s41109-017-0027-2} +} + \subsection{On direct blockmodelling}{ Doreian, Patrick, Vladimir Batagelj, and Anuska Ferligoj. 2005. diff --git a/tests/testthat/test-measure_features.R b/tests/testthat/test-measure_features.R index 0a3981b..6bc7a74 100644 --- a/tests/testthat/test-measure_features.R +++ b/tests/testthat/test-measure_features.R @@ -30,3 +30,12 @@ test_that("net_waves works", { # expect_equal(net_waves(ison_adolescents), 1) expect_values(net_by_waves(wavenet), 3) }) + +test_that("net_by_waves counts waves held in a `time` attribute", { + # These hold their waves under `time` rather than `wave`, so reading only + # `wave` reported one wave for each of them. + expect_values(net_by_waves(ison_monks), 3) + expect_values(net_by_waves(ison_fraternity), 15) + expect_values(net_by_waves(ison_classmates), 4) + expect_values(net_by_waves(ison_adolescents), 1) +}) From bcd99468630cdbafbf37934ce7711a9c3b011ece Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 15:00:03 +0200 Subject: [PATCH 66/68] Avoid using new datasets for testing --- R/measure_change.R | 2 +- tests/testthat/test-measure_features.R | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/R/measure_change.R b/R/measure_change.R index 2de8d25..630e21b 100644 --- a/R/measure_change.R +++ b/R/measure_change.R @@ -12,7 +12,7 @@ NULL #' @rdname measure_periods #' @examples -#' net_by_waves(ison_classmates) +#' net_by_waves(ison_monks) #' @export net_by_waves <- function(.data){ .data <- manynet::expect_nodes(.data) diff --git a/tests/testthat/test-measure_features.R b/tests/testthat/test-measure_features.R index 6bc7a74..d9808bc 100644 --- a/tests/testthat/test-measure_features.R +++ b/tests/testthat/test-measure_features.R @@ -31,11 +31,13 @@ test_that("net_waves works", { expect_values(net_by_waves(wavenet), 3) }) +timenet <- ison_adolescents %>% + mutate_ties(time = c(1, 1, 1, 1, 2, 2, 2, 3, 3, 3)) + test_that("net_by_waves counts waves held in a `time` attribute", { - # These hold their waves under `time` rather than `wave`, so reading only - # `wave` reported one wave for each of them. + # Every bundled longitudinal network holds its waves under `time` rather + # than `wave`, so reading only `wave` reported one wave for each of them. + expect_values(net_by_waves(timenet), 3) expect_values(net_by_waves(ison_monks), 3) - expect_values(net_by_waves(ison_fraternity), 15) - expect_values(net_by_waves(ison_classmates), 4) expect_values(net_by_waves(ison_adolescents), 1) }) From 40c7a5e9a8c9f6fc06657ed3e2ec4ade65fec502 Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 16:57:39 +0200 Subject: [PATCH 67/68] Using faster irps_books example in the tutorial instead of irps_blogs --- inst/tutorials/netrics2/community.Rmd | 5 +- inst/tutorials/netrics2/community.html | 159 +++++++++--------- .../testthat/test-measure_closure_contract.R | 17 +- vignettes/articles/community.Rmd | 5 +- 4 files changed, 100 insertions(+), 86 deletions(-) diff --git a/inst/tutorials/netrics2/community.Rmd b/inst/tutorials/netrics2/community.Rmd index 37aaff2..e17123d 100644 --- a/inst/tutorials/netrics2/community.Rmd +++ b/inst/tutorials/netrics2/community.Rmd @@ -1434,9 +1434,10 @@ The answer no longer rests on a single run of a single algorithm. It costs considerably more time, which is why it is not the default, and it is ignored on a network small enough for `node_in_optimal()`, where the exact maximum is already available. +On a network the size of `irps_books` this may take about twenty seconds. -```{r inconsensus, exercise = TRUE, exercise.setup = "manip-fri"} -node_in_community(irps_blogs, consensus = TRUE) +```{r inconsensus, exercise = TRUE, exercise.setup = "manip-fri", purl = FALSE} +node_in_community(irps_books, consensus = TRUE) ``` ```{r alg-comp, echo=FALSE, purl = FALSE} diff --git a/inst/tutorials/netrics2/community.html b/inst/tutorials/netrics2/community.html index 32bc99f..ebe6658 100644 --- a/inst/tutorials/netrics2/community.html +++ b/inst/tutorials/netrics2/community.html @@ -1516,11 +1516,12 @@

    Detecting communities

    longer rests on a single run of a single algorithm. It costs considerably more time, which is why it is not the default, and it is ignored on a network small enough for node_in_optimal(), -where the exact maximum is already available.

    +where the exact maximum is already available. On a network the size of +irps_books this may take about twenty seconds.

    -
    node_in_community(irps_blogs, consensus = TRUE)
    +
    node_in_community(irps_books, consensus = TRUE)
    @@ -2183,15 +2184,15 @@

    Glossary

    @@ -2342,11 +2343,11 @@

    Glossary

    @@ -2567,31 +2568,31 @@

    Glossary

    @@ -2964,31 +2965,31 @@

    Glossary

    @@ -3010,13 +3011,13 @@

    Glossary

    @@ -3105,19 +3106,19 @@

    Glossary

    @@ -3208,23 +3209,23 @@

    Glossary

    @@ -3636,25 +3637,25 @@

    Glossary

    @@ -3744,15 +3745,15 @@

    Glossary

    @@ -4287,23 +4288,23 @@

    Glossary

    @@ -4323,18 +4324,18 @@

    Glossary

    @@ -4464,18 +4465,18 @@

    Glossary

    list(label = "manip-fri", code = "# to_giant() returns an object that includes only the main component without any smaller components or isolates\n(friends <- to_giant(friends))\n(friends <- to_undirected(friends))\ngraphr(friends)", opts = list(label = "\"manip-fri\"", exercise = "TRUE", exercise.setup = "\"separatingnets\""), engine = "r"), - list(label = "inconsensus", code = "node_in_community(irps_blogs, consensus = TRUE)", + list(label = "inconsensus", code = "node_in_community(irps_books, consensus = TRUE)", opts = list(label = "\"inconsensus\"", exercise = "TRUE", - exercise.setup = "\"manip-fri\""), engine = "r")), - code_check = NULL, error_check = NULL, check = NULL, solution = NULL, - tests = NULL, options = list(eval = FALSE, echo = TRUE, results = "markup", - tidy = FALSE, tidy.opts = NULL, collapse = FALSE, prompt = FALSE, - comment = NA, highlight = FALSE, size = "normalsize", - background = "#F7F7F7", strip.white = TRUE, cache = 0, - cache.path = "community_cache/html/", cache.vars = NULL, - cache.lazy = TRUE, dependson = NULL, autodep = FALSE, - cache.rebuild = FALSE, fig.keep = "high", fig.show = "asis", - fig.align = "default", fig.path = "community_files/figure-html/", + exercise.setup = "\"manip-fri\"", purl = "FALSE"), + engine = "r")), code_check = NULL, error_check = NULL, + check = NULL, solution = NULL, tests = NULL, options = list( + eval = FALSE, echo = TRUE, results = "markup", tidy = FALSE, + tidy.opts = NULL, collapse = FALSE, prompt = FALSE, comment = NA, + highlight = FALSE, size = "normalsize", background = "#F7F7F7", + strip.white = TRUE, cache = 0, cache.path = "community_cache/html/", + cache.vars = NULL, cache.lazy = TRUE, dependson = NULL, + autodep = FALSE, cache.rebuild = FALSE, fig.keep = "high", + fig.show = "asis", fig.align = "default", fig.path = "community_files/figure-html/", dev = "png", dev.args = NULL, dpi = 192, fig.ext = "png", fig.width = 6.5, fig.height = 4, fig.env = "figure", fig.cap = NULL, fig.scap = NULL, fig.lp = "fig:", fig.subcap = NULL, @@ -4483,10 +4484,10 @@

    Glossary

    fig.retina = 2, external = TRUE, sanitize = FALSE, interval = 1, aniopts = "controls,loop", warning = TRUE, error = FALSE, message = TRUE, render = NULL, ref.label = NULL, child = NULL, - engine = "r", split = FALSE, include = TRUE, purl = TRUE, + engine = "r", split = FALSE, include = TRUE, purl = FALSE, max.print = 1000, label = "inconsensus", exercise = TRUE, - exercise.setup = "manip-fri", code = "node_in_community(irps_blogs, consensus = TRUE)", - out.width.px = 624, out.height.px = 384, params.src = "inconsensus, exercise = TRUE, exercise.setup = \"manip-fri\"", + exercise.setup = "manip-fri", code = "node_in_community(irps_books, consensus = TRUE)", + out.width.px = 624, out.height.px = 384, params.src = "inconsensus, exercise = TRUE, exercise.setup = \"manip-fri\", purl = FALSE", fig.num = 0, exercise.df_print = "paged", exercise.checker = "NULL"), engine = "r", version = "4"), class = c("r", "tutorial_exercise" ))) @@ -4494,48 +4495,48 @@

    Glossary

    @@ -4619,7 +4620,7 @@

    Glossary

    diff --git a/tests/testthat/test-measure_closure_contract.R b/tests/testthat/test-measure_closure_contract.R index 78c07fa..d229a0b 100644 --- a/tests/testthat/test-measure_closure_contract.R +++ b/tests/testthat/test-measure_closure_contract.R @@ -1,7 +1,18 @@ test_that("network closures meet the measure contract", { - check_measure_contract(measure_rosters$closure_net, manynet::ison_networkers, - level = "net") - expect_declared(measure_rosters$closure_net, manynet::ison_networkers) + # `net_by_equivalency()` counts four-cycles, and where the network is + # one-mode it does so by enumerating every three-path from every node. + # That takes about fifteen seconds on `ison_networkers`, and the sweep runs + # each measure twice, so it dominated this package's test time. It is a + # two-mode measure, so it is swept on a two-mode network instead; its + # one-mode result is asserted in test-measure_closure.R. + onemode <- measure_rosters$closure_net[ + setdiff(names(measure_rosters$closure_net), "net_by_equivalency")] + check_measure_contract(onemode, manynet::ison_networkers, level = "net") + expect_declared(onemode, manynet::ison_networkers) + + twomode <- measure_rosters$closure_net["net_by_equivalency"] + check_measure_contract(twomode, manynet::ison_southern_women, level = "net") + expect_declared(twomode, manynet::ison_southern_women) }) test_that("node closures meet the measure contract", { diff --git a/vignettes/articles/community.Rmd b/vignettes/articles/community.Rmd index 0afbc59..154c82f 100644 --- a/vignettes/articles/community.Rmd +++ b/vignettes/articles/community.Rmd @@ -926,9 +926,10 @@ The answer no longer rests on a single run of a single algorithm. It costs considerably more time, which is why it is not the default, and it is ignored on a network small enough for `node_in_optimal()`, where the exact maximum is already available. +On a network the size of `irps_books` this may take about twenty seconds. -```{r inconsensus} -node_in_community(irps_blogs, consensus = TRUE) +```{r inconsensus, purl = FALSE} +node_in_community(irps_books, consensus = TRUE) ``` ::: {.callout} From f9f517685040a259d981539a694d77d9a27c1dcf Mon Sep 17 00:00:00 2001 From: James Hollway Date: Fri, 28 Aug 2026 17:14:56 +0200 Subject: [PATCH 68/68] Added `connectivity=` for whether strong or weak connectedness matters --- NEWS.md | 8 ++++ R/measure_cohesion.R | 17 ++++---- R/member_components.R | 55 +++++++------------------ R/netrics-defunct.R | 23 +++++++++++ inst/tutorials/netrics2/community.Rmd | 27 ++++++------ man-roxygen/param_connectivity.R | 7 ++++ man/defunct.Rd | 15 +++++++ man/measure_cohesion.Rd | 22 +++++----- man/measure_periods.Rd | 2 +- man/member_components.Rd | 26 ++++++------ tests/testthat/helper-netrics.R | 3 +- tests/testthat/test-measure_cohesion.R | 18 ++++++++ tests/testthat/test-member_components.R | 37 +++++++++++++---- vignettes/articles/community.Rmd | 9 ++-- 14 files changed, 170 insertions(+), 99 deletions(-) create mode 100644 man-roxygen/param_connectivity.R diff --git a/NEWS.md b/NEWS.md index cce69c8..47f738c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -12,6 +12,7 @@ - `param_times` - `param_variant` - `param_standardized` + - `param_connectivity` - Updated the website function overview to use the `NEWS.md` family headings - Updated the README to recommend installing the whole family via `{migraph}` @@ -94,6 +95,9 @@ - Fixed `node_by_information()` on rectangular matrices by using `manynet::to_multilevel()` - Fixed `net_by_independence()` erroring on multilevel networks by measuring whole - Fixed `net_by_waves()` reporting one wave where waves are held as `time` +- Added `connectivity=` to `net_by_components()` for counting weak as well as strong components + - Defaults to `"strong"`, so existing scripts are unaffected + - The connectivity counted is reported as the measure's `variant` when the result is printed ## Memberships @@ -135,6 +139,10 @@ - Renamed `Kmax=` to `max_k=` in the community and equivalence functions - Renamed `num_groups=` to `groups=` in `node_in_roulette()` - Renamed `cluster_by=` to `split=` in `node_in_core()` +- Added `connectivity=` to `node_in_component()` for weak as well as strong component membership + - Defaults to `"strong"`, so existing scripts are unaffected + - Ignored for undirected networks, where the two notions coincide + - Deprecated `node_in_weak()` and `node_in_strong()` ## Motifs diff --git a/R/measure_cohesion.R b/R/measure_cohesion.R index b4feaa0..79ea6ab 100644 --- a/R/measure_cohesion.R +++ b/R/measure_cohesion.R @@ -9,12 +9,13 @@ #' of possible ties. #' - `net_by_compactness()` measures the average closeness of all pairs #' of nodes in the network. -#' - `net_by_components()` measures the number of (strong) components -#' in the network. +#' - `net_by_components()` measures the number of components +#' in the network, either strongly or weakly connected. #' - `net_by_independence()` measures the independence number, #' or size of the largest independent set in the network. #' #' @template param_data +#' @template param_connectivity #' @family cohesion #' @template net_measure #' @section Signed networks: @@ -109,21 +110,19 @@ net_by_compactness <- function(.data) { } #' @rdname measure_cohesion -#' @section Components: -#' To get the 'weak' components of a directed graph, -#' please use `manynet::to_undirected()` first. #' @importFrom igraph components #' @examples #' net_by_components(fict_thrones) -#' net_by_components(to_undirected(fict_thrones)) +#' net_by_components(fict_thrones, connectivity = "weak") #' @export -net_by_components <- function(.data){ +net_by_components <- function(.data, connectivity = c("strong", "weak")){ + connectivity <- match.arg(connectivity) .data <- manynet::expect_nodes(.data) object <- manynet::as_igraph(.data) - make_network_measure(igraph::components(object, mode = "strong")$no, + make_network_measure(igraph::components(object, mode = connectivity)$no, object, call = deparse(sys.call()), measure = "number of components", range = c(1, Inf), - normalization = "none") + normalization = "none", variant = connectivity) } #' @rdname measure_cohesion diff --git a/R/member_components.R b/R/member_components.R index 1f9bf60..994b03a 100644 --- a/R/member_components.R +++ b/R/member_components.R @@ -1,62 +1,39 @@ #' Memberships in components -#' @description +#' @description #' These functions create a vector of nodes' memberships in components: -#' -#' - `node_in_component()` assigns nodes' component membership -#' using edge direction where available. -#' - `node_in_weak()` assigns nodes' component membership -#' ignoring edge direction. -#' - `node_in_strong()` assigns nodes' component membership -#' based on edge direction. -#' -#' In graph theory, components, sometimes called connected components, +#' +#' - `node_in_component()` assigns nodes' component membership, +#' in either the strongly or the weakly connected components. +#' +#' In graph theory, components, sometimes called connected components, #' are induced subgraphs from partitioning the nodes into disjoint sets. #' All nodes that are members of the same partition as _i_ are reachable #' from _i_. -#' -#' For directed networks, +#' +#' For directed networks, #' strongly connected components consist of subgraphs where there are paths #' in each direction between member nodes. #' Weakly connected components consist of subgraphs where there is a path #' in either direction between member nodes. -#' +#' #' @template param_data +#' @template param_connectivity #' @template node_member #' @name member_components NULL -#' @rdname member_components +#' @rdname member_components #' @importFrom igraph components #' @examples #' ison_monks |> to_uniplex("esteem") |> #' mutate_nodes(comp = node_in_component()) +#' ison_monks |> to_uniplex("esteem") |> +#' mutate_nodes(comp = node_in_component(connectivity = "weak")) #' @export -node_in_component <- function(.data){ +node_in_component <- function(.data, connectivity = c("strong", "weak")){ + connectivity <- match.arg(connectivity) .data <- manynet::expect_nodes(.data) if(!manynet::is_graph(.data)) .data <- manynet::as_igraph(.data) # nocov - make_node_member(igraph::components(.data, mode = "strong")$membership, + make_node_member(igraph::components(.data, mode = connectivity)$membership, .data) } - -#' @rdname member_components -#' @importFrom igraph components -#' @export -node_in_weak <- function(.data){ - .data <- manynet::expect_nodes(.data) - if(!manynet::is_graph(.data)) .data <- manynet::as_igraph(.data) # nocov - make_node_member(igraph::components(.data, mode = "weak")$membership, - .data) -} - -#' @rdname member_components -#' @importFrom igraph components -#' @export -node_in_strong <- function(.data){ - .data <- manynet::expect_nodes(.data) - if(!manynet::is_graph(.data)) .data <- manynet::as_igraph(.data) # nocov - make_node_member(igraph::components(.data, mode = "strong")$membership, - .data) -} - - - diff --git a/R/netrics-defunct.R b/R/netrics-defunct.R index 9013921..20007df 100644 --- a/R/netrics-defunct.R +++ b/R/netrics-defunct.R @@ -40,4 +40,27 @@ net_x_mixed <- function(.data, object2) { if(missing(object2)) net_x_triad(.data) else net_x_triad(.data, object2) } +#' @describeIn defunct Deprecated on 2026-08-28. +#' Folded into `node_in_component(connectivity = "weak")`, which now takes +#' the connectivity wanted as an argument rather than splitting the same +#' calculation across three function names. +#' @template param_data +#' @export +node_in_weak <- function(.data) { + .Deprecated("node_in_component", package = "netrics", + old = "node_in_weak") + node_in_component(.data, connectivity = "weak") +} + +#' @describeIn defunct Deprecated on 2026-08-28. +#' Folded into `node_in_component(connectivity = "strong")`, which is also +#' what `node_in_component()` does by default. +#' @template param_data +#' @export +node_in_strong <- function(.data) { + .Deprecated("node_in_component", package = "netrics", + old = "node_in_strong") + node_in_component(.data, connectivity = "strong") +} + # nocov end \ No newline at end of file diff --git a/inst/tutorials/netrics2/community.Rmd b/inst/tutorials/netrics2/community.Rmd index e17123d..706227f 100644 --- a/inst/tutorials/netrics2/community.Rmd +++ b/inst/tutorials/netrics2/community.Rmd @@ -718,8 +718,7 @@ We're interested here in how many there are. By default, the `net_by_components()` function will return the number of _strong_ components for directed networks. -For _weak_ components, you will need to first make the network -`r gloss("undirected")`. +For _weak_ components, add `connectivity = "weak"`. Remember the difference between weak and strong components? ```{r weak-strong, echo = FALSE, purl = FALSE} @@ -745,17 +744,17 @@ net_by_components(friends) ``` ```{r comp-no-hint-2, purl = FALSE} -# Now let's look at the number of components for objects connected by an undirected edge -# Note: to_undirected() returns an object with all tie direction removed, -# so any pair of nodes with at least one directed edge -# will be connected by an undirected edge in the new network. -net_by_components(to_undirected(friends)) +# Now let's look at the number of components ignoring tie direction +# Note: connectivity = "weak" treats every tie as if it were undirected, +# so any pair of nodes with at least one directed tie between them +# counts as connected. +net_by_components(friends, connectivity = "weak") ``` ```{r comp-no-solution} # note that friends is a directed network net_by_components(friends) -net_by_components(to_undirected(friends)) +net_by_components(friends, connectivity = "weak") ``` ```{r comp-interp, echo = FALSE, purl = FALSE} @@ -787,12 +786,12 @@ that can be used to color nodes in `graphr()`: ```{r comp-memb-hint-1, purl = FALSE} friends <- friends |> - mutate_nodes(weak_comp = node_in_component(to_undirected(friends)), + mutate_nodes(weak_comp = node_in_component(friends, connectivity = "weak"), strong_comp = node_in_component(friends)) # node_in_component returns a vector of nodes' memberships to components in the network # here, we are adding the nodes' membership to components as an attribute in the network # alternatively, we can also use the function `add_node_attribute()` -# eg. `add_node_attribute(friends, "weak_comp", node_in_component(to_undirected(friends)))` +# eg. `add_node_attribute(friends, "weak_comp", node_in_component(friends, connectivity = "weak"))` ``` ```{r comp-memb-hint-2, purl = FALSE} @@ -804,7 +803,7 @@ graphr(friends, node_color = "strong_comp") + ggtitle("Strong components") ```{r comp-memb-solution} friends <- friends |> - mutate_nodes(weak_comp = node_in_component(to_undirected(friends)), + mutate_nodes(weak_comp = node_in_component(friends, connectivity = "weak"), strong_comp = node_in_component(friends)) graphr(friends, node_color = "weak_comp") + ggtitle("Weak components") + graphr(friends, node_color = "strong_comp") + ggtitle("Strong components") @@ -838,7 +837,7 @@ before we ask the subtler question of who clusters with whom. ::: {.callout} **In brief**: Components partition a network by `r gloss("reachability")`: `net_by_components()` counts them -(strong by default for directed networks; wrap in `to_undirected()` for weak), +(strong by default for directed networks; add `connectivity = "weak"` for weak), and `node_in_component()` returns each node's membership, ready to map onto `node_color` in `graphr()`. ::: @@ -884,7 +883,7 @@ even if we look at weak components and not just strong components. ```{r blogcomp, exercise = TRUE, exercise.setup = "blogsize"} node_in_component(blogs) -node_in_component(to_undirected(blogs)) +node_in_component(blogs, connectivity = "weak") ``` ### The giant component {#the-giant-component} @@ -1540,7 +1539,7 @@ Along the way, you have learned to use these functions: | `to_mode1()`, `to_mode2()` | projects a two-mode network onto its row or column nodes, with a `similarity` option | | `tie_weights()` | extracts the tie weights, e.g. of a projection | | `net_by_equivalency()` | equivalence/reinforcement measured on the two-mode network itself | -| `net_by_components()` | number of (strong) components; wrap in `to_undirected()` for weak | +| `net_by_components()` | number of components, strong by default, or `connectivity = "weak"` | | `node_in_component()` | each node's component membership | | `node_is_isolate()` | flags isolates (sum it to count them) | | `delete_nodes()` | removes chosen (e.g. sampled) nodes | diff --git a/man-roxygen/param_connectivity.R b/man-roxygen/param_connectivity.R new file mode 100644 index 0000000..fb7947a --- /dev/null +++ b/man-roxygen/param_connectivity.R @@ -0,0 +1,7 @@ +#' @param connectivity Character string, "weak" treats a directed network's +#' components as if the network were undirected, and "strong" requires ties +#' in both directions between members. +#' This is ignored for undirected networks, where the two notions coincide. +#' Note that the default differs by function: functions that assert or count +#' connectedness default to "strong", while functions that scope or split a +#' network into components default to "weak". diff --git a/man/defunct.Rd b/man/defunct.Rd index b483685..2b9f8bd 100644 --- a/man/defunct.Rd +++ b/man/defunct.Rd @@ -4,11 +4,17 @@ \alias{defunct} \alias{node_by_coreness} \alias{net_x_mixed} +\alias{node_in_weak} +\alias{node_in_strong} \title{Functions that have been renamed, superseded, or are no longer working} \usage{ node_by_coreness(.data, coreness = NULL, direction = c("all", "out", "in")) net_x_mixed(.data, object2) + +node_in_weak(.data) + +node_in_strong(.data) } \arguments{ \item{.data}{A network object of class \code{stocnet}, \code{igraph}, \code{tbl_graph}, \code{network}, or similar. @@ -54,5 +60,14 @@ Folded into \code{net_x_triad()}, which now takes the multilevel census whenever it is given both a one-mode and a two-mode network, rather than reporting that no such option exists. +\item \code{node_in_weak()}: Deprecated on 2026-08-28. +Folded into \code{node_in_component(connectivity = "weak")}, which now takes +the connectivity wanted as an argument rather than splitting the same +calculation across three function names. + +\item \code{node_in_strong()}: Deprecated on 2026-08-28. +Folded into \code{node_in_component(connectivity = "strong")}, which is also +what \code{node_in_component()} does by default. + }} \keyword{internal} diff --git a/man/measure_cohesion.Rd b/man/measure_cohesion.Rd index c99f68b..5d94131 100644 --- a/man/measure_cohesion.Rd +++ b/man/measure_cohesion.Rd @@ -12,7 +12,7 @@ net_by_density(.data) net_by_compactness(.data) -net_by_components(.data) +net_by_components(.data, connectivity = c("strong", "weak")) net_by_independence(.data) } @@ -20,6 +20,14 @@ net_by_independence(.data) \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{connectivity}{Character string, "weak" treats a directed network's +components as if the network were undirected, and "strong" requires ties +in both directions between members. +This is ignored for undirected networks, where the two notions coincide. +Note that the default differs by function: functions that assert or count +connectedness default to "strong", while functions that scope or split a +network into components default to "weak".} } \value{ A \code{network_measure} numeric score. @@ -38,8 +46,8 @@ These functions return values or vectors relating to how cohesive a network is: of possible ties. \item \code{net_by_compactness()} measures the average closeness of all pairs of nodes in the network. -\item \code{net_by_components()} measures the number of (strong) components -in the network. +\item \code{net_by_components()} measures the number of components +in the network, either strongly or weakly connected. \item \code{net_by_independence()} measures the independence number, or size of the largest independent set in the network. } @@ -89,19 +97,13 @@ partly to avoid confusion with the unrelated \code{\link[=net_by_efficiency]{net_by_efficiency()}} (Krackhardt) and \code{\link[=node_by_efficiency]{node_by_efficiency()}} (Burt). } -\section{Components}{ - -To get the 'weak' components of a directed graph, -please use \code{manynet::to_undirected()} first. -} - \examples{ net_by_density(ison_adolescents) net_by_density(ison_southern_women) net_by_compactness(ison_adolescents) net_by_compactness(ison_southern_women) net_by_components(fict_thrones) -net_by_components(to_undirected(fict_thrones)) +net_by_components(fict_thrones, connectivity = "weak") net_by_independence(ison_adolescents) net_by_independence(fict_actually) } diff --git a/man/measure_periods.Rd b/man/measure_periods.Rd index ce7ef83..f5c22a0 100644 --- a/man/measure_periods.Rd +++ b/man/measure_periods.Rd @@ -26,7 +26,7 @@ All can be retrieved with \code{attr()}. \code{net_by_waves()} measures the number of waves in longitudinal network data. } \examples{ -net_by_waves(ison_classmates) +net_by_waves(ison_monks) } \seealso{ Other change: diff --git a/man/member_components.Rd b/man/member_components.Rd index cd2df80..d88cc43 100644 --- a/man/member_components.Rd +++ b/man/member_components.Rd @@ -3,20 +3,22 @@ \name{member_components} \alias{member_components} \alias{node_in_component} -\alias{node_in_weak} -\alias{node_in_strong} \title{Memberships in components} \usage{ -node_in_component(.data) - -node_in_weak(.data) - -node_in_strong(.data) +node_in_component(.data, connectivity = c("strong", "weak")) } \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{connectivity}{Character string, "weak" treats a directed network's +components as if the network were undirected, and "strong" requires ties +in both directions between members. +This is ignored for undirected networks, where the two notions coincide. +Note that the default differs by function: functions that assert or count +connectedness default to "strong", while functions that scope or split a +network into components default to "weak".} } \value{ A \code{node_member} character vector the length of the nodes in the network, @@ -27,12 +29,8 @@ then the assignments will be labelled with the nodes' names. \description{ These functions create a vector of nodes' memberships in components: \itemize{ -\item \code{node_in_component()} assigns nodes' component membership -using edge direction where available. -\item \code{node_in_weak()} assigns nodes' component membership -ignoring edge direction. -\item \code{node_in_strong()} assigns nodes' component membership -based on edge direction. +\item \code{node_in_component()} assigns nodes' component membership, +in either the strongly or the weakly connected components. } In graph theory, components, sometimes called connected components, @@ -49,6 +47,8 @@ in either direction between member nodes. \examples{ ison_monks |> to_uniplex("esteem") |> mutate_nodes(comp = node_in_component()) +ison_monks |> to_uniplex("esteem") |> + mutate_nodes(comp = node_in_component(connectivity = "weak")) } \seealso{ Other memberships: diff --git a/tests/testthat/helper-netrics.R b/tests/testthat/helper-netrics.R index e0f30c5..f276b56 100644 --- a/tests/testthat/helper-netrics.R +++ b/tests/testthat/helper-netrics.R @@ -82,7 +82,8 @@ collect_functions <- function(pattern, package = "netrics"){ # Renamed functions are kept as warning wrappers in R/netrics-defunct.R for one # release. They delegate to their replacement, so sweeping them only produces # deprecation warnings for a name on its way out. -defunct_fns <- c("node_by_coreness", "net_x_mixed") +defunct_fns <- c("node_by_coreness", "net_x_mixed", + "node_in_weak", "node_in_strong") funs_objs <- mget(setdiff(ls("package:netrics"), defunct_fns), inherits = TRUE) # data_objs <- mget(ls("package:manynet"), inherits = TRUE) diff --git a/tests/testthat/test-measure_cohesion.R b/tests/testthat/test-measure_cohesion.R index f3c908d..2d70413 100644 --- a/tests/testthat/test-measure_cohesion.R +++ b/tests/testthat/test-measure_cohesion.R @@ -2,6 +2,24 @@ test_that("network components works", { expect_equal(as.numeric(net_by_components(ison_adolescents)), 1) }) +test_that("net_by_components' connectivity argument works", { + # a directed acyclic network has one weak component but as many strong + # components as it has nodes, so the two connectivities must differ + dag <- manynet::create_tree(6, directed = TRUE) + expect_equal(as.numeric(net_by_components(dag, connectivity = "weak")), 1) + expect_equal(as.numeric(net_by_components(dag, connectivity = "strong")), + as.numeric(manynet::net_nodes(dag))) + # the no-argument call is unchanged, that is, strong + expect_equal(as.numeric(net_by_components(dag)), + as.numeric(net_by_components(dag, connectivity = "strong"))) + # connectivity is ignored for undirected networks + expect_equal(as.numeric(net_by_components(ison_adolescents, + connectivity = "strong")), + as.numeric(net_by_components(ison_adolescents, + connectivity = "weak"))) + expect_error(net_by_components(dag, connectivity = "loose")) +}) + test_that("network cohesion works", { expect_equal(as.numeric(net_by_cohesion(ison_southern_women)), 2) }) diff --git a/tests/testthat/test-member_components.R b/tests/testthat/test-member_components.R index 877c331..e8828ab 100644 --- a/tests/testthat/test-member_components.R +++ b/tests/testthat/test-member_components.R @@ -2,17 +2,40 @@ test_that("node_in_component works", { comp <- ison_monks %>% to_uniplex("esteem") %>% node_in_component() expect_s3_class(comp, "node_member") - expect_equal(length(unique(comp)), + expect_equal(length(unique(comp)), c(net_by_components(to_uniplex(ison_monks, "esteem")))) - expect_equal(length(unique(comp)), - length(unique(node_in_strong(to_uniplex(ison_monks, "esteem"))))) comp <- ison_monks %>% to_uniplex("esteem") %>% - to_undirected() %>% + to_undirected() %>% node_in_component() - expect_equal(length(unique(comp)), - length(unique(node_in_weak(to_uniplex(ison_monks, "esteem"))))) + expect_equal(length(unique(comp)), + length(unique(node_in_component(to_uniplex(ison_monks, "esteem"), + connectivity = "weak")))) +}) + +test_that("node_in_component's connectivity argument works", { + # a directed acyclic network has one weak component but as many strong + # components as it has nodes, so the two connectivities must differ + dag <- manynet::create_tree(6, directed = TRUE) + expect_equal(length(unique(node_in_component(dag, connectivity = "weak"))), 1) + expect_equal(length(unique(node_in_component(dag, connectivity = "strong"))), + as.numeric(manynet::net_nodes(dag))) + # the no-argument call is unchanged, that is, strong + expect_equal(c(node_in_component(dag)), + c(node_in_component(dag, connectivity = "strong"))) + # connectivity is ignored for undirected networks + expect_equal(c(node_in_component(to_undirected(dag), connectivity = "strong")), + c(node_in_component(to_undirected(dag), connectivity = "weak"))) + expect_error(node_in_component(dag, connectivity = "loose")) }) test_that("node_in_component works for two-mode networks", { expect_output(print(node_in_component(ison_southern_women)), "1 group") -}) \ No newline at end of file +}) + +test_that("deprecated component functions still return correct results", { + esteem <- to_uniplex(ison_monks, "esteem") + expect_warning(weak <- node_in_weak(esteem)) + expect_warning(strong <- node_in_strong(esteem)) + expect_equal(c(weak), c(node_in_component(esteem, connectivity = "weak"))) + expect_equal(c(strong), c(node_in_component(esteem, connectivity = "strong"))) +}) diff --git a/vignettes/articles/community.Rmd b/vignettes/articles/community.Rmd index 154c82f..355008a 100644 --- a/vignettes/articles/community.Rmd +++ b/vignettes/articles/community.Rmd @@ -482,8 +482,7 @@ We're interested here in how many there are. By default, the `net_by_components()` function will return the number of _strong_ components for directed networks. -For _weak_ components, you will need to first make the network -`r gloss("undirected")`. +For _weak_ components, add `connectivity = "weak"`. Remember the difference between weak and strong components? ::: {.callout} @@ -518,7 +517,7 @@ before we ask the subtler question of who clusters with whom. ::: {.callout} **In brief**: Components partition a network by `r gloss("reachability")`: `net_by_components()` counts them -(strong by default for directed networks; wrap in `to_undirected()` for weak), +(strong by default for directed networks; add `connectivity = "weak"` for weak), and `node_in_component()` returns each node's membership, ready to map onto `node_color` in `graphr()`. ::: @@ -564,7 +563,7 @@ even if we look at weak components and not just strong components. ```{r blogcomp} node_in_component(blogs) -node_in_component(to_undirected(blogs)) +node_in_component(blogs, connectivity = "weak") ``` ### The giant component {#the-giant-component} @@ -1007,7 +1006,7 @@ Along the way, you have learned to use these functions: | `to_mode1()`, `to_mode2()` | projects a two-mode network onto its row or column nodes, with a `similarity` option | | `tie_weights()` | extracts the tie weights, e.g. of a projection | | `net_by_equivalency()` | equivalence/reinforcement measured on the two-mode network itself | -| `net_by_components()` | number of (strong) components; wrap in `to_undirected()` for weak | +| `net_by_components()` | number of components, strong by default, or `connectivity = "weak"` | | `node_in_component()` | each node's component membership | | `node_is_isolate()` | flags isolates (sum it to count them) | | `delete_nodes()` | removes chosen (e.g. sampled) nodes |