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/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 4616fe9..e122a9e 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,10 +26,63 @@ 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
+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.
+
+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`).
+- 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.
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
@@ -68,9 +107,17 @@ 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()`
+- 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.
+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)
@@ -86,6 +133,99 @@ 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**.
+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 =` |
+| `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.
+
+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.
+
+### 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 |
+| `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 |
+| `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:
+
+- 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:
@@ -96,6 +236,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.
@@ -104,95 +323,266 @@ 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`.
-
-### 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.
-
+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
+
+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.
+ 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.
+- 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 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 `{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).
+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
+
+`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
+
+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.
+
+#### 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
+
+`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
+- 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
+ - 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:
+
+> 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/.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..b61b3d7 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
@@ -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
@@ -132,7 +146,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/.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/DESCRIPTION b/DESCRIPTION
index 564de64..c8a656c 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
+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.
Marks identify structural positions, measures quantify network properties,
@@ -15,7 +15,7 @@ Encoding: UTF-8
LazyData: true
Depends:
R (>= 4.1.0),
- manynet (>= 2.1.2)
+ manynet (>= 2.2.3)
Imports:
dplyr,
igraph (>= 2.1.0)
@@ -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 d0c9e5c..ed17676 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_rich)
+export(coreness_transition)
export(k_elbow)
export(k_gap)
export(k_silhouette)
@@ -17,12 +21,16 @@ 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)
export(net_by_components)
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)
@@ -35,11 +43,13 @@ 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)
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)
@@ -64,6 +74,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)
@@ -80,7 +91,9 @@ 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)
export(node_by_degree)
export(node_by_distance)
@@ -99,6 +112,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)
@@ -107,6 +121,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)
@@ -119,6 +134,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)
@@ -128,8 +144,10 @@ 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_motif)
export(node_in_optimal)
export(node_in_partition)
export(node_in_regular)
@@ -156,13 +174,22 @@ 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(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)
@@ -183,53 +210,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
+)
diff --git a/NEWS.md b/NEWS.md
index 7d7476d..47f738c 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,8 +1,189 @@
+# netrics 1.0.0
+
+## Package
+
+- Removed CRAN version check from `.onAttach()` making `library(netrics)` faster to attach
+- Fixed release workflow doubling `actions/actions/checkout` path segment
+- 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`
+ - `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}`
+
+## Measures
+
+- Improved `make_*_measure()` to record algorithm details so results can be read without the script/manual
+ - `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` 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
+ - `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()` 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
+- 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
+- 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
+- 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 rescales finite scores onto `[0,1]` and places cut nodes at 0
+- 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
+- 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
+- Improved `node_by_subgraph()`
+ - Now honours tie weights
+ - 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
+ - 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
+- 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 undefined objects when substituting an inapplicable index
+- Fixed `net_by_transmissibility()` declaring itself a proportion
+- 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 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
+
+- Added `k=` to community detection functions to target a specific number of communities (thanks @tomasdiviak)
+ - 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 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=`
+- Added `consensus=` to `node_in_community()` for combining partitions of all applicable algorithms
+ - 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()`
+ - 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=`
+ - `"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
+- 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 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()`
+- 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
+
+- 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
+- Improved `node_x_tie()`
+ - 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()` 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
+ - `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
+
+- 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
## 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
@@ -89,7 +270,7 @@
- Added network analysis tutorials from `{manynet}`
-## Members
+## Memberships
- Added more explanation for `node_in_partition()`
@@ -104,7 +285,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()`
@@ -116,7 +297,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/class_metrics.R b/R/class_metrics.R
index 58622aa..b18ae60 100644
--- a/R/class_metrics.R
+++ b/R/class_metrics.R
@@ -22,14 +22,150 @@ 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
+# "proportional" shares of a fixed total, summing to 1
+# "none" raw values on the measure's own scale
+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, 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
+}
+
+# `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
+}
+
+# `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
+}
+
+# `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,
+# 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, 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)
- out
+ set_measure_attributes(out, measure, range, normalization, variant)
}
-make_tie_measure <- function(out, .data) {
+make_tie_measure <- function(out, .data, measure = NULL, range = NULL,
+ normalization = NULL, variant = NULL) {
class(out) <- c("tie_measure", class(out))
if(manynet::is_labelled(.data)){
tie_names <- attr(igraph::E(.data), "vnames")
@@ -39,24 +175,28 @@ 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, variant)
}
-make_network_measure <- function(out, .data, call) {
+make_network_measure <- function(out, .data, call, measure = 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
- out
+ set_measure_attributes(out, measure, range, normalization, variant)
}
-make_mode_measure <- function(out, .data, call) {
+make_mode_measure <- function(out, .data, call, measure = 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
- out
+ set_measure_attributes(out, measure, range, normalization, variant)
}
make_node_member <- function(out, .data) {
@@ -83,3 +223,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", "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
+# 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)) "rich" 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),
+ rich = coreness_rich(.data, direction = direction),
+ transition = coreness_transition(.data, direction = direction),
+ hub = coreness_hub(.data, direction = direction))
+}
diff --git a/R/measure_centrality_between.R b/R/measure_centrality_between.R
index 333951d..dde5007 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
@@ -33,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
@@ -60,32 +89,40 @@ 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
-#' @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
@@ -100,7 +137,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
@@ -112,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_.
@@ -128,7 +167,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
@@ -152,13 +195,17 @@ 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, "proportional", "none"))
}
# 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.
#'
@@ -175,7 +222,24 @@ 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.
+#' 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))
@@ -187,8 +251,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 +301,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 +333,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 +344,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 +392,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 73ba6cf..d4b35b9 100644
--- a/R/measure_centrality_closeness.R
+++ b/R/measure_centrality_closeness.R
@@ -13,7 +13,15 @@
#' 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_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
#' from another node in the network.
@@ -22,51 +30,64 @@
#' - `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,
+#' 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.
+#' 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
+#' @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:
-#' Closeness centrality, status centrality, or barycenter centrality is
-#' defined as the reciprocal of the farness or distance, \eqn{d},
+#' @section Closeness centrality:
+#' 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)
#' @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))
@@ -76,12 +97,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:
@@ -93,6 +115,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.
@@ -103,13 +132,37 @@ 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){
+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 {
+ 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)
+ diag(dists) <- Inf # exclude self from own score
+ 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"
+ }
+ 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
@@ -127,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
@@ -141,15 +203,104 @@ 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
- out <- rowSums(out <= cutoff)
+ diag(out) <- Inf # exclude self from own score
+ # 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)
- 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
-#' @section Information centrality:
+#' @rdname measure_central_close
+#' @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:
+#' \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)
+ node_by_harmonic(.data, normalized = normalized, decay = decay,
+ direction = match.arg(direction))
+}
+
+#' @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,
+ 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
+#' @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
@@ -168,6 +319,10 @@ node_by_reach <- function(.data, normalized = TRUE, cutoff = 2){
#' 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.
@@ -183,11 +338,19 @@ node_by_reach <- function(.data, normalized = TRUE, cutoff = 2){
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)
- 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, "proportional", "none"))
}
#' @rdname measure_central_close
@@ -210,8 +373,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,
@@ -227,28 +394,47 @@ 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
#' @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}
#' 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,
@@ -262,11 +448,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
@@ -314,8 +515,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
@@ -340,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.
@@ -358,17 +564,19 @@ 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)
#' @export
tie_by_closeness <- function(.data, normalized = TRUE){
.data <- manynet::expect_ties(.data)
- edge_adj <- manynet::to_ties(.data)
+ edge_adj <- .to_linegraph(.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 ####
@@ -382,7 +590,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 +615,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,
@@ -437,8 +646,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
@@ -501,8 +712,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
@@ -512,7 +725,54 @@ 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
+#' @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()),
+ measure = "decay centralisation",
+ range = `if`(normalized, c(0, 1), c(0, Inf)),
+ normalization = `if`(normalized, "normalized", "none"))
+}
+
+#' @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()),
+ measure = "integration centralisation",
+ range = `if`(normalized, c(0, 1), c(0, Inf)),
+ normalization = `if`(normalized, "normalized", "none"))
}
#' @rdname measure_centralisation_close
@@ -522,6 +782,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 69fc5b6..e07e9a8 100644
--- a/R/measure_centrality_degree.R
+++ b/R/measure_centrality_degree.R
@@ -12,15 +12,29 @@
#' - `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,
+#' 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.
+#' @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
@@ -40,9 +54,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}
@@ -77,50 +97,67 @@ 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 = 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
@@ -151,31 +188,28 @@ 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))
- make_node_measure(out, .data)
+ 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")
}
-#' @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)
+# 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.
+# `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, 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))
+ out[manynet::node_names(layer)] <- deg
+ unname(out)
}
#' @rdname measure_central_degree
@@ -194,13 +228,16 @@ 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 ####
#' 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
#'
@@ -217,16 +254,18 @@ 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
tie_by_degree <- function(.data, normalized = TRUE){
.data <- manynet::expect_ties(.data)
- edge_adj <- manynet::to_ties(.data)
+ edge_adj <- .to_linegraph(.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 ####
@@ -267,6 +306,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.
@@ -300,8 +346,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
@@ -332,12 +380,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 4bc839e..57e9acb 100644
--- a/R/measure_centrality_eigen.R
+++ b/R/measure_centrality_eigen.R
@@ -15,17 +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
@@ -43,50 +58,66 @@ 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()`.
-#' @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)
#' @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
@@ -115,13 +146,18 @@ node_by_eigenvector <- function(.data, normalized = TRUE, scale = 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
@@ -129,33 +165,44 @@ 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, "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:
@@ -183,84 +230,209 @@ node_by_power <- function(.data, normalized = TRUE, scale = FALSE, exponent = 1)
#' 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)
- make_node_measure(igraph::alpha_centrality(manynet::as_igraph(.data),
- alpha = alpha),
- .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 = 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)
- make_node_measure(igraph::page_rank(manynet::as_igraph(.data))$vector,
- .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),
+ damping = decay)$vector,
+ .data, measure = "pagerank centrality",
+ 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
-node_by_authority <- function(.data){
+#' @export
+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
+#' @rdname measure_central_eigen
+#' @template param_decay
+#' @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.
#' 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,
+ walks = c("all", "odd", "even"),
+ method = NULL){
+ walks <- resolve_method(walks, method, "walks")
.data <- manynet::expect_nodes(.data)
- make_node_measure(igraph::subgraph_centrality(manynet::as_igraph(.data)),
- .data)
+ walks <- match.arg(walks, c("all", "odd", "even"))
+ decay <- check_decay(decay)
+ 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(walks,
+ all = "subgraph centrality",
+ odd = "odd subgraph centrality",
+ even = "even subgraph centrality"),
+ 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
+# `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, 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(walks,
+ 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
+#' @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 ####
#' 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.
@@ -278,16 +450,17 @@ node_by_subgraph <- function(.data){
#' @template tie_measure
NULL
-#' @rdname measure_centralities_eigen
+#' @rdname measure_central_tie_eigen
#' @examples
#' tie_by_eigenvector(ison_adolescents)
#' @export
tie_by_eigenvector <- function(.data, normalized = TRUE){
.data <- manynet::expect_ties(.data)
- edge_adj <- manynet::to_ties(.data)
+ edge_adj <- .to_linegraph(.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 ####
@@ -345,8 +518,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
@@ -361,8 +536,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/R/measure_change.R b/R/measure_change.R
index 88f71f8..630e21b 100644
--- a/R/measure_change.R
+++ b/R/measure_change.R
@@ -11,16 +11,23 @@
NULL
#' @rdname measure_periods
+#' @examples
+#' net_by_waves(ison_monks)
#' @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)
} 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 ####
@@ -43,6 +50,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_closure.R b/R/measure_closure.R
index 1d82264..050e607 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.
@@ -22,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
@@ -34,10 +40,19 @@ NULL
#' @examples
#' net_by_reciprocity(ison_southern_women)
#' @export
-net_by_reciprocity <- function(.data, method = "default") {
+net_by_reciprocity <- function(.data, variant = c("default", "ratio"),
+ method = NULL) {
+ variant <- resolve_method(variant, method, "variant")
.data <- manynet::expect_nodes(.data)
- make_network_measure(igraph::reciprocity(manynet::as_igraph(.data), mode = method),
- .data, call = deparse(sys.call()))
+ 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 = variant),
+ .data, call = deparse(sys.call()),
+ measure = "reciprocity", range = c(0, 1),
+ normalization = "normalized", variant = variant)
}
#' @rdname measure_closure
@@ -47,15 +62,64 @@ 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
-#' @section Equivalency:
-#' The `net_by_equivalency()` function calculates the Robins and Alexander (2004)
+#' @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.
+#'
+#' 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
+#' ## 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)
+ # 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
+ 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()),
+ measure = "cyclicality", range = c(0, 1),
+ normalization = "normalized")
+}
+
+#' @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.
+#' 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.
@@ -67,8 +131,10 @@ net_by_transitivity <- 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)
@@ -78,7 +144,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,
@@ -92,7 +157,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
@@ -129,7 +196,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 ####
@@ -156,34 +225,67 @@ 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(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.
+ # 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")
}
-#' @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,
@@ -193,6 +295,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 216bd61..79ea6ab 100644
--- a/R/measure_cohesion.R
+++ b/R/measure_cohesion.R
@@ -7,14 +7,33 @@
#'
#' - `net_by_density()` measures the ratio of ties to the number
#' of possible ties.
-#' - `net_by_components()` measures the number of (strong) components
-#' in the network.
+#' - `net_by_compactness()` measures the average closeness of all pairs
+#' of nodes 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:
+#' `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.
+#' @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
@@ -26,43 +45,107 @@ 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
+#' @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(.to_positive(.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()),
+ measure = "compactness", range = c(0, 1),
+ normalization = "normalized")
}
#' @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(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,
- object, call = deparse(sys.call()))
+ make_network_measure(igraph::components(object, mode = connectivity)$no,
+ object, call = deparse(sys.call()),
+ measure = "number of components", range = c(1, Inf),
+ normalization = "none", variant = connectivity)
}
#' @rdname measure_cohesion
#' @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) && !.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)))
}
- 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 ####
@@ -78,6 +161,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
@@ -88,10 +179,12 @@ NULL
#' @export
net_by_diameter <- function(.data){
.data <- manynet::expect_nodes(.data)
- object <- manynet::as_igraph(.data)
- make_network_measure(igraph::diameter(object,
+ object <- manynet::as_igraph(.to_positive(.data))
+ 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
@@ -102,10 +195,12 @@ 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()))
+ object, call = deparse(sys.call()),
+ measure = "average path length", range = c(0, Inf),
+ normalization = "none")
}
# Fragmentation ####
@@ -144,8 +239,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
@@ -156,8 +253,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
@@ -170,11 +269,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){
@@ -183,6 +284,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 e27dd25..57c538c 100644
--- a/R/measure_features.R
+++ b/R/measure_features.R
@@ -3,94 +3,29 @@
#' 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.
#' - `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
-#' @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
@@ -140,96 +75,18 @@ 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()))
-}
-
-#' @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()))
+ 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.
-#' @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
@@ -242,14 +99,15 @@ net_by_modularity <- 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
@@ -273,18 +131,18 @@ net_by_modularity <- 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 {
@@ -292,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))
}
}
@@ -301,18 +159,26 @@ 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()))
+ .data, call = deparse(sys.call()),
+ measure = "small-world coefficient",
+ 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`(variant == "SWI", "normalized", "none"),
+ variant = variant)
}
-
#' @rdname measure_features
#' @importFrom igraph fit_power_law
#' @references
@@ -348,10 +214,50 @@ 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:
+#' 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
+#' `walks = "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, 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")
}
-#' @rdname measure_features
+#' @rdname measure_features
#' @source `{signnet}` by David Schoch
#' @references
#' ## On balance theory
@@ -370,21 +276,31 @@ net_by_scalefree <- 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")
}
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,
@@ -420,13 +336,386 @@ 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")
}
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 ####
+
+#' 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 (at the default resolution) | 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.
+#' @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
+#' 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.
+#' @template param_coreness
+#' @section Core-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.
+#' “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,
+ variant = c("correlation","ident","ndiff", "diff"),
+ coreness = NULL,
+ 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)
+
+ 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
+ # 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(variant == "correlation"){
+ out <- stats::cor(c(obs), c(ideal))
+ } else if(variant == "ident"){
+ out <- sqrt(sum((obs - ideal)^2))
+ } else if(variant %in% c("ndiff","diff")){
+ # Sort nodes by coreness
+ c_scores <- node_by_core(.data, coreness = coreness,
+ direction = direction)
+ 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(variant == "ndiff"){
+ out <- (diff1 + diff2) / length(c_scores) # Normalize
+ } else if(variant == "diff"){
+ out <- (diff1 + diff2) * sqrt(sum(mark))
+ }
+ } 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(variant,
+ correlation = "core-periphery correlation",
+ ident = "core-periphery distance",
+ ndiff = "normalised core-periphery difference",
+ diff = "core-periphery difference"),
+ range = switch(variant,
+ correlation = c(-1, 1),
+ ident = c(0, Inf),
+ ndiff = , diff = c(-Inf, Inf)),
+ normalization = "none", variant = variant)
+}
+
+#' @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()),
+ 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. 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.
+#'
+#' 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()),
+ 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()),
+ measure = "modularity",
+ range = `if`(resolution == 1, c(-0.5, 1), c(-Inf, 1)),
+ normalization = "none")
+}
+
+#' @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()),
+ measure = "blockmodel inconsistency", range = c(0, Inf),
+ normalization = "none")
+}
+
+# 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/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..b09f6d7 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
@@ -243,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)
@@ -258,5 +276,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_cliques.R b/R/member_cliques.R
index c224249..f03c4b0 100644
--- a/R/member_cliques.R
+++ b/R/member_cliques.R
@@ -33,17 +33,20 @@
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.
-#' @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,18 +58,26 @@ 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, 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]
}
@@ -90,40 +101,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/R/member_community.R b/R/member_community.R
index 1e13ff5..b9ca101 100644
--- a/R/member_community.R
+++ b/R/member_community.R
@@ -1,93 +1,332 @@
# 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, 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(max_k, n), at_k), .data, k) else
+ at_k(k)
+ 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, max_k){
+ if(is.null(k)) get(alg)(.data) else
+ suppressWarnings(get(alg)(.data, k = k, max_k = max_k))
+}
+
+# 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, max_k, 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, max_k))
+ }), 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.
+#' @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
+#' ## 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){
+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)
- 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
+ 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, 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)
} 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(!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)
- }
- 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)
- }
- 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)
+ 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)
+ if(length(idx) != length(poss)) idx <- seq_along(poss)
+ candidates <- lapply(idx, function(comm){
+ memb <- run_alg(poss[comm], .data, k, max_k)
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]]
+ 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.",
+ "Returning {length(unique(out))} instead.")
+ out
}
}
-# #' @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
@@ -107,14 +346,17 @@ 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.
#'
#' @template param_data
+#' @template param_k
#' @family community
#' @template node_member
NULL
@@ -150,10 +392,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.
@@ -164,50 +408,60 @@ 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, max_k = 8L, Kmax = NULL){
+ max_k <- resolve_max_k(max_k, Kmax)
.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, max_k, .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
@@ -216,8 +470,7 @@ node_in_partition <- function(.data){
#' 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.
@@ -241,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
@@ -272,16 +526,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
@@ -301,14 +558,18 @@ 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, 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)
+ # 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.")
@@ -319,13 +580,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, 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))),
+ FUN.VALUE = numeric(1))
+ at_k(which.max(mods))
+ })
+ make_node_member(memb, .data)
}
}
@@ -337,6 +601,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.
@@ -345,17 +611,21 @@ 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, 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)){
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, max_k, .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
@@ -375,6 +645,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.
@@ -384,21 +656,87 @@ 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, 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)){
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, max_k, .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
+#' @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.
+#'
+#' 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.
+#' "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, 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)){
+ manynet::snet_info("This algorithm only works for undirected networks.",
+ "Converting to undirected")
+ .data <- manynet::to_undirected(.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, max_k, .data, at_k = at_k,
+ default = function() igraph::cluster_label_prop(gr)$membership)
+ make_node_member(memb, .data)
}
# Hierarchical community clustering ####
@@ -423,6 +761,7 @@ node_in_leiden <- function(.data, resolution = 1){
#' and their logic or domain of inspiration.
#'
#' @template param_data
+#' @template param_k
#' @template node_member
#' @family community
NULL
@@ -446,18 +785,22 @@ NULL
#' @examples
#' node_in_betweenness(ison_adolescents)
#' @export
-node_in_betweenness <- function(.data){
+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)
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, max_k, .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
}
@@ -479,15 +822,18 @@ node_in_betweenness <- function(.data){
#' @examples
#' node_in_greedy(ison_adolescents)
#' @export
-node_in_greedy <- function(.data){
+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)))
- out <- clust$membership
- make_node_member(out, .data)
- out <- make_node_member(out, .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)
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
}
@@ -508,19 +854,22 @@ node_in_greedy <- function(.data){
#' @examples
#' node_in_eigen(ison_adolescents)
#' @export
-node_in_eigen <- function(.data){
+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)){
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, max_k, .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
}
@@ -530,8 +879,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.
@@ -541,15 +891,18 @@ 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, max_k = 8L, steps = 4, Kmax = NULL){
+ max_k <- resolve_max_k(max_k, Kmax)
.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, max_k, .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/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/member_core.R b/R/member_core.R
index d5400a9..018423f 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,51 +19,38 @@ NULL
#' and which to the periphery.
#' It seeks to minimize the following quantity:
#' \deqn{Z(S_1) = \sum_{(i
#' 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 ####
@@ -77,7 +60,7 @@ node_is_core <- function(.data, centrality = c("degree", "eigenvector")){
#' @description
#' These functions identify nodes belonging to (some level of) the core of a network:
#'
-#' - `node_by_coreness()` returns a continuous measure of how closely each node
+#' - `node_by_core()` returns a continuous measure of how closely each node
#' resembles a typical core node.
#' - `node_by_kcoreness()` assigns nodes to their level of k-coreness.
#'
@@ -111,28 +94,42 @@ 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
+#' @template param_coreness
+#' @section Coreness:
+#' Where `node_is_core()` forces a yes or no answer,
+#' `node_by_core()` grades how core-like each node is on a scale from
+#' 0 to 1. The two agree on which method to use and read the same
+#' `coreness` and `direction` arguments, so the mark is always the cut of
+#' the measure returned here.
+#'
+#' Each method uses as much of the network as it can. The rich-core and hub
+#' methods read tie weights and tie direction directly. The correlation and
+#' transition methods compare the network against a symmetric ideal, so they
+#' symmetrise a directed network and say that they have done so.
+#' To keep a method from using a property, transform the network first with
+#' e.g. [manynet::to_undirected()] or [manynet::to_unweighted()].
+#'
+#' This function was called `node_by_coreness()` prior to version 1.0.0.
+#' It is now named for the property, as `node_is_core()` and `node_in_core()`
+#' are, which also frees "coreness" from meaning two different things: the
+#' continuous score here, and the peeling depth of `node_by_kcoreness()`.
#' @examples
-#' node_by_coreness(ison_adolescents)
+#' node_by_core(ison_adolescents)
+#' node_by_core(ison_networkers, direction = "out")
#' @export
-node_by_coreness <- function(.data) {
+node_by_core <- function(.data, coreness = NULL,
+ direction = c("all","out","in")) {
.data <- manynet::expect_nodes(.data)
- A <- manynet::as_matrix(.data)
- n <- nrow(A)
- obj_fun <- function(c) {
- ideal <- outer(c, c)
- val <- suppressWarnings(cor(as.vector(A), as.vector(ideal)))
- if (!is.finite(val)) return(1e6) # Penalize non-finite values
- return(-val) # Negative for maximization
- }
- # Initial guess: all nodes have coreness 0.5
- 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)
+ direction <- match.arg(direction)
+ coreness <- check_coreness(.data, coreness)
+ out <- run_coreness(.data, coreness, direction)
+ make_node_measure(out$coreness, .data, measure = "coreness", range = c(0, 1),
+ normalization = "scaled", variant = coreness)
}
# Membering core ####
@@ -151,9 +148,22 @@ 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.
+#' 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".
+#' 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",
@@ -161,50 +171,95 @@ 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 `split` 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")) {
- 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.")
+ split = c("bins","quantiles","kmeans"),
+ coreness = NULL,
+ direction = c("all","out","in","both"),
+ cluster_by = NULL) {
+ split <- resolve_split(split, cluster_by)
.data <- manynet::expect_nodes(.data)
- contin <- node_by_coreness(.data)
- 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),
- probs = seq(0, 1, length.out = groups + 1)),
- include.lowest = TRUE, labels = FALSE)),
- kmeans = stats::kmeans(as.numeric(contin), centers = groups)$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]
+ direction <- match.arg(direction)
+ if(direction == "both") return(.core_four_sets(.data))
+ 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))
+ 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)
+}
+
+# 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)
-}
\ No newline at end of file
+}
+
+# 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/member_equivalence.R b/R/member_equivalence.R
index da21c05..1e5638d 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.
@@ -39,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
@@ -53,20 +57,26 @@ 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)
- 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))
+ 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
out <- make_node_member(stats::cutree(hc, k), .data)
@@ -84,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)){
@@ -92,35 +103,119 @@ 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
+#' @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.
+#' @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
+#' 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){
+ max_k = 8L,
+ regularity = c("rolesim", "rege"),
+ 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")
+ manynet::snet_info("Calculating regular equivalence using",
+ "{.fn regularity_{regularity}}.")
+ mat <- switch(regularity,
+ rolesim = regularity_rolesim(.data, decay = decay),
+ rege = regularity_rege(.data))
+ node_in_equivalence(.data, mat,
+ k = k, cluster = cluster, distance = distance, max_k = max_k)
+}
+
+#' @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.
+#'
+#' 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
+node_in_motif <- function(.data,
+ k = c("silhouette", "elbow", "strict"),
+ cluster = c("hierarchical", "concor","cosine"),
+ distance = c("euclidean", "maximum", "manhattan",
+ "canberra", "binary", "minkowski"),
+ 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,",
- "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,
- k = k, cluster = cluster, distance = distance, Kmax = Kmax)
+ node_in_equivalence(.data, mat,
+ k = k, cluster = cluster, distance = distance, max_k = max_k)
}
#' @rdname member_equivalence
@@ -135,9 +230,72 @@ 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
+#' @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 == 0) soln <- .strongPerturb(soln)
+ }
+ out <- make_node_member(out, .data)
+ attr(out, "k") <- k
+ out
}
diff --git a/R/method_coreness.R b/R/method_coreness.R
new file mode 100644
index 0000000..ff1413a
--- /dev/null
+++ b/R/method_coreness.R
@@ -0,0 +1,407 @@
+# 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_core()] and [node_in_core()] then use.
+#'
+#' - `coreness_correlation()` fits the network to an ideal core-periphery
+#' pattern by correlation.
+#' - `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_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
+#' 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)]
+}
+
+# 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){
+ 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_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
+#' the same network return the same answer.
+#' @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_rich} 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 <- .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)))]]
+ 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.
+#'
+#' 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_rich(ison_networkers)
+#' @export
+coreness_rich <- 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_rich} 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/R/method_k.R b/R/method_k.R
index 740c6c3..ef687b4 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
@@ -41,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.
@@ -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.
@@ -59,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")
@@ -80,34 +110,12 @@ 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))
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)
@@ -121,12 +129,12 @@ 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
# k identification method
- elbow_finder(dafr$clusters, dafr$correlations)
+ elbow_point(dafr$clusters, dafr$correlations)
}
#' @rdname method_kselect
@@ -155,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()
@@ -189,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) {
@@ -212,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))
}
@@ -227,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))
}
@@ -238,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/method_regularity.R b/R/method_regularity.R
new file mode 100644
index 0000000..cde227d
--- /dev/null
+++ b/R/method_regularity.R
@@ -0,0 +1,177 @@
+# Recursive role similarity ####
+
+#' 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.
+#'
+#' - `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.
+#' They differ in how they pair up two nodes' alters.
+#' @template param_data
+#' @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
+#' ## 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_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
+#' similarity, and then averages over it:
+#' \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.
+#'
+#' 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
+regularity_rolesim <- function(.data, decay = 0.15, beta = NULL){
+ .data <- manynet::expect_nodes(.data)
+ 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, decay, 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_regularity
+#' @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 `regularity_rolesim()` for unweighted networks.
+#' @export
+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 regularity_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/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/R/motif_brokerage.R b/R/motif_brokerage.R
index fb89862..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
@@ -139,7 +135,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 +168,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 ####
@@ -179,7 +177,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
@@ -197,6 +195,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 3133d37..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
@@ -25,15 +30,31 @@ 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_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))
}))
- } 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]])
@@ -46,11 +67,11 @@ 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))
}))
- } 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]])
@@ -65,9 +86,9 @@ 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 (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"))),
@@ -313,15 +334,20 @@ 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}.
#'
+#' @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
#' @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
@@ -391,15 +417,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 {
@@ -492,31 +566,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))
@@ -543,7 +609,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/motif_cliques.R b/R/motif_cliques.R
new file mode 100644
index 0000000..25ddf0d
--- /dev/null
+++ b/R/motif_cliques.R
@@ -0,0 +1,82 @@
+# 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_clique_size = c(3, 3))
+#' @export
+node_x_clique <- function(.data, min_clique_size = 3){
+ .data <- manynet::expect_nodes(.data)
+ twomode <- manynet::is_twomode(.data)
+ 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))
+ .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_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_clique_size[1] &&
+ sum(modes[cl]) >= min_clique_size[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/R/motif_composition.R b/R/motif_composition.R
new file mode 100644
index 0000000..a0130d8
--- /dev/null
+++ b/R/motif_composition.R
@@ -0,0 +1,408 @@
+# 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.
+#' @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
+#' @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)
+#' 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)){
+ # `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)
+ 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)){
+ 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/R/netrics-defunct.R b/R/netrics-defunct.R
index 06566b3..20007df 100644
--- a/R/netrics-defunct.R
+++ b/R/netrics-defunct.R
@@ -14,4 +14,53 @@
#' along with a warning to use new function naming in the future.
NULL
+#' @describeIn defunct Deprecated on 2026-08-27.
+#' Renamed `node_by_core()`, for symmetry with `node_is_core()` and
+#' `node_in_core()`, and so that "coreness" names only the peeling depth
+#' that `node_by_kcoreness()` returns.
+#' @template param_data
+#' @template param_coreness
+#' @export
+node_by_coreness <- function(.data, coreness = NULL,
+ direction = c("all", "out", "in")) {
+ .Deprecated("node_by_core", package = "netrics",
+ old = "node_by_coreness")
+ 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)
+}
+
+#' @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/R/netrics-utils.R b/R/netrics-utils.R
index dd51796..d209ac8 100644
--- a/R/netrics-utils.R
+++ b/R/netrics-utils.R
@@ -40,6 +40,33 @@ seq_nodes <- function(.data){
seq.int(manynet::net_nodes(.data))
}
+# Compatibility shim: manynet renamed `to_ties()` to `to_linegraph()` in 2.3.0.
+# The name is resolved at call time, so this uses `to_linegraph()` where it is
+# available and never raises the deprecation warning that `to_ties()` gives
+# there. Remove this and call `manynet::to_linegraph()` directly once manynet
+# 2.3.x is on CRAN and the DESCRIPTION floor is raised again.
+.to_linegraph <- function(.data) {
+ ns <- asNamespace("manynet")
+ fn <- if (is.null(ns$to_linegraph)) ns$to_ties else ns$to_linegraph
+ fn(.data)
+}
+
+# Compatibility shim: `manynet::net_waves()` has existed since manynet 2.2.0,
+# but only learned to read a `time` tie attribute in 2.3.0, and every bundled
+# longitudinal network holds its waves there rather than under `wave`. At the
+# declared floor it therefore reports one wave for `ison_monks`, which
+# `manynet::net_waves()` on 2.3.1 reports as three. The count is taken here as
+# well, so the answer does not depend on which manynet is installed.
+# Remove this and call `manynet::net_waves()` directly once the DESCRIPTION
+# floor is raised past 2.3.0.
+.net_waves <- function(.data) {
+ attr_waves <- vapply(c("wave", "time"), function(a) {
+ vals <- manynet::tie_attribute(.data, a)
+ if(is.null(vals)) 1L else length(unique(vals))
+ }, FUN.VALUE = integer(1))
+ max(manynet::net_waves(.data), attr_waves)
+}
+
# Resolve membership to a vector:
# if a single character string naming a network attribute is provided,
# retrieve that attribute as a vector; otherwise return the value as-is.
@@ -52,4 +79,81 @@ seq_nodes <- function(.data){
}
}
-# nocov end
\ No newline at end of file
+
+# 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
+
+# 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
+}
+
+# `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/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()
}
diff --git a/README.Rmd b/README.Rmd
index de9980c..ab10af8 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", "net_x_mixed")
+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 = "`, `"), "`")
@@ -47,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)
@@ -58,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.
@@ -102,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,
@@ -133,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")`
@@ -142,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 78c2576..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
@@ -84,42 +85,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,17 +144,20 @@ 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()`
-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
@@ -167,10 +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_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_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 +188,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 +205,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_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()`,
+ `tie_is_imbalanced()`
- *Resilience*: `net_by_adhesion()`, `net_by_cohesion()`,
`node_by_bridges()`, `node_is_cutpoint()`, `tie_by_cohesion()`,
`tie_is_bridge()`
@@ -206,19 +220,25 @@ 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
### 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/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/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")`.
-
-
::: {.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
-
-
### 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(),
+ " ")),
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(),
+ " ")),
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(),
+ " ")),
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(),
+ " ")),
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}
-
-
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
-
-
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..4d7c7d6 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 @@
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?
Family
Reads a node’s…
-
Built on
+
Traverses
Answers the question
Well-suited when
@@ -866,15 +889,17 @@
Which centrality?
Degree
activity (radial, local)
-
its direct ties
+
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
+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
+
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
@@ -883,7 +908,7 @@
Which centrality?
Betweenness
brokerage (medial, global)
-
shortest paths that pass through it
+
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
@@ -892,21 +917,45 @@
Which centrality?
Eigenvector
standing (radial, global)
-
walks of all lengths, weighted by neighbours’ scores
+
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
+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. 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 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.
@@ -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.
-
-
-
-
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.↩︎
-
@@ -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..706227f 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`,
@@ -417,8 +418,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.
-
-
### Reciprocity {#reciprocity}
First, let's calculate reciprocity in the task network.
@@ -719,15 +718,14 @@ 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}
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.
'),
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
@@ -746,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}
@@ -788,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}
@@ -805,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")
@@ -839,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()`.
:::
@@ -885,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}
@@ -1012,7 +1010,18 @@ graphr(blogs, node_color = "Leaning")
net_by_modularity(blogs, membership = node_attribute(blogs, "Leaning"))
```
-
+```{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.
'),
+ 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
@@ -1096,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}
@@ -1123,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)
```
@@ -1302,6 +1312,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",
@@ -1334,6 +1424,21 @@ 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.
+On a network the size of `irps_books` this may take about twenty seconds.
+
+```{r inconsensus, exercise = TRUE, exercise.setup = "manip-fri", purl = FALSE}
+node_in_community(irps_books, consensus = TRUE)
+```
+
```{r alg-comp, echo=FALSE, purl = FALSE}
question("Which algorithm provides the 'best' membership assignment here?",
answer("Walktrap",
@@ -1365,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).
@@ -1379,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.
:::
@@ -1431,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/inst/tutorials/netrics2/community.html b/inst/tutorials/netrics2/community.html
index 01805ac..ebe6658 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
@@ -497,7 +500,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.
-
Reciprocity
First, let’s calculate reciprocity in the task network. While one
@@ -768,10 +770,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 +989,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.
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
@@ -1174,12 +1179,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.
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:
+
+
Each node is moved to whichever neighbouring group most improves
+modularity.
+
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.
+
+
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.
+
+
+
+
+
+
+
+
+
@@ -1432,6 +1508,22 @@
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. On a network the size of
+irps_books this may take about twenty seconds.
+
+
node_in_community(irps_books, consensus = TRUE)
+
+
@@ -1442,11 +1534,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
@@ -1457,12 +1548,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.
@@ -1726,20 +1819,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 +1858,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 +1926,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",
@@ -1884,34 +1956,34 @@
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 +4424,121 @@
Glossary
+
+
+
+
+
@@ -4287,19 +4564,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 +4615,12 @@
Glossary
diff --git a/inst/tutorials/netrics3/position.Rmd b/inst/tutorials/netrics3/position.Rmd
index 3880bd0..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}
@@ -156,7 +159,8 @@ 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_
-- [ ] Contract a network into a reduced graph of positions with `to_blocks()`
+- [ ] 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_blockmodel()`
**Choose your own data**: The worked examples below use `ison_algebra`,
a multiplex network of interactions in an algebra class
@@ -882,8 +886,6 @@ profile for the whole network. Both need a _directed_ network and a
Dendrograms ·
Choosing k
-
-
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
@@ -900,7 +902,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(),
+ '
')),
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.",
@@ -1021,7 +1024,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.
@@ -1225,8 +1228,6 @@ and a cut-point choosing the number of clusters
Summarising profiles ·
Plotting blockmodels
-
-
### Summarising profiles {#summarising-profiles}
Ok, so now we have a result from establishing nodes' membership in
@@ -1250,8 +1251,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.
-
-
One option that can be useful for characterising what
the profile of ties (partners) is for each position/equivalence class
is to use `summary()`.
@@ -1379,9 +1378,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(),
+ '
')),
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.
'),
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.",
@@ -1399,6 +1399,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:
@@ -1419,7 +1497,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.
@@ -1427,7 +1505,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
@@ -1461,7 +1539,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.
@@ -1511,9 +1589,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 +1698,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,12 +1771,16 @@ 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 |
+| `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..5fd871d 100644
--- a/inst/tutorials/netrics3/position.html
+++ b/inst/tutorials/netrics3/position.html
@@ -207,6 +207,9 @@
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
@@ -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
+→ B → C), 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
-
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
-
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.
-
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,16 +1562,16 @@
Reduced graphs
and thus the network will be complex.
From blocks to ties
-
to_blocks() 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.
Notice how this is just a compact, numerical version of the
@@ -1352,8 +1604,8 @@
Naming positions
roles.
In brief:
-to_blocks() 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_blocks()
+
+
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 @@
diff --git a/inst/tutorials/netrics4/topology.Rmd b/inst/tutorials/netrics4/topology.Rmd
index 870cace..daffea7 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()),
@@ -676,6 +675,8 @@ attaches to like.
On this page:
Ideal graphs ·
Assignment ·
+Weighted ·
+Directed ·
Coreness
### Core-periphery graphs {#core-periphery-graphs}
@@ -851,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"}
@@ -866,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}
@@ -900,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
@@ -1265,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_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 |
| `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..f9f0c62 100644
--- a/inst/tutorials/netrics4/topology.html
+++ b/inst/tutorials/netrics4/topology.html
@@ -224,7 +224,6 @@
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 @@
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.
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.
+
+
+
+
+
+
+
+
+
+
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.
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.
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.
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_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
@@ -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",
@@ -1776,19 +1952,19 @@
Glossary
learnr:::store_exercise_cache(structure(list(label = "empty", 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!\",",
@@ -1837,19 +2013,19 @@
Glossary
learnr:::store_exercise_cache(structure(list(label = "star", 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!\",",
@@ -1898,19 +2074,19 @@
Glossary
learnr:::store_exercise_cache(structure(list(label = "tree", 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,27 +2127,27 @@
Glossary
@@ -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 @@