Skip to content

Edge weights, node metrics, subnetwork extraction, null models - #8

Merged
agallinat merged 11 commits into
mainfrom
feat/defect-sweep
Oct 5, 2026
Merged

agallinat merged 11 commits into
mainfrom
feat/defect-sweep

Conversation

@agallinat

Copy link
Copy Markdown
Owner

See NEWS.md. Three behaviour changes: weights ignored unless requested, degree-matched diffusion null by default, robustness_analysis() no
longer re-seeds the RNG.

agallinat and others added 11 commits October 3, 2026 13:17
None of these was visible by reading the code; each was concealed either by a
plausible-looking number or by a return value nothing asserted.

- robustness_analysis() called set.seed(seed) unconditionally, so the
  documented default seed = NULL ran set.seed(NULL) and re-seeded from the
  clock. `set.seed(42); robustness_analysis(g)` was not reproducible and the
  caller's RNG stream was destroyed as a side effect. Now only seeds when asked.

- calculate_roles() returned plot/roles_definitions/result with no `graph` and
  no `method`, breaking the shared return vocabulary. It now annotates the graph
  with module/role_z/role_p/role, keyed by vertex name, and reports the
  thresholds actually applied. test-return-contract.R now lists it by name.

- The five participation-coefficient boundaries were written out three times --
  classifier, roles_definitions table, plot bands -- and had drifted. The R5/R6
  boundary was 0.30 in the classifier but 0.25 in the other two, so the shaded
  "provincial hub" region disagreed with the classification it illustrated and
  the table documented a threshold the code did not use. They now come from one
  vector in R/utils-roles.R, overridable via `thresholds`. The R2/R3 boundary is
  corrected from 0.60 to the published 0.62.

- compare_networks() reported one number twice: edge_overlap was the identical
  expression to jaccard_similarity. It is now the overlap coefficient, which
  divides by the smaller edge set and is the informative companion to Jaccard
  when two networks differ greatly in size.

- compare_networks() crashed on an edgeless graph, where compute_ccdf() returns
  zero rows and recycling a scalar into a zero-row frame is an error. Found by a
  test written for the defect above. compute_ccdf() now returns an empty table
  for degenerate input, matching summarize_graph_metrics(), which reports NaN
  rather than refusing.

- compare_networks() used cat() where message() was wanted.

find_modules()$module_table and robustness_analysis()$summary gain `result`
aliases so the return vocabulary has no exceptions left; the old names are kept
and documented as deprecated.

425 -> 481 expectations. R CMD check --as-cran unchanged at 2 NOTEs, both
unavoidable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
netkit had three different, undocumented weight semantics at once. Measured on
a 60-node sample_gnp with a confidence score attached as E(g)$weight:

  summarize_graph_metrics()$Diameter   3.11 vs 6.00 unweighted   -> cost
  summarize_graph_metrics()$Avg_path   1.39 vs 2.88              -> cost
  find_hubs()$result$betweenness       changes, r = 0.68         -> cost
  summarize_graph_metrics()$Modularity 0.47 vs 0.38              -> strength
  network_diffusion()                  bit-identical             -> ignored

igraph reads the `weight` attribute implicitly and gives it opposite meanings in
different functions, while netkit's own matrix code built the adjacency without
`attr` and dropped it entirely. So attaching a confidence score -- the most
common edge annotation in the domain netkit targets -- inverted every path-based
metric, was read correctly by community detection, and vanished from diffusion.

R/utils-weights.R adds as_netkit_weights(), the companion to as_netkit_graph():
it resolves `weights` (NULL, an attribute name, or a numeric vector), validates
it, and derives *both* a strength and a cost vector so each metric receives the
one it needs. `weight_type` declares which kind the input is.

Threaded through summarize_graph_metrics(), prepare_diffusion(),
network_diffusion(), network_diffusion_with_pvalues(), greedy_seed_selection(),
find_hubs(), find_bottlenecks(), calculate_roles(), find_modules(),
robustness_analysis(), compare_networks() and plot_CCDF().

Notable decisions:

- `weights = NULL` ignores weights even when the graph has the attribute, and
  warns that it is doing so. Explicit beats inherited-by-accident, and the
  warning means the behaviour is never silent again. This differs from igraph.
- cluster_edge_betweenness() is given costs, not strengths: it works by removing
  high-betweenness edges, so the strength vector would invert its notion of
  which edges are central. Every other algorithm gets strengths.
- collapse_to_undirected() carries weights as edge attributes through
  as_undirected(mode = "collapse"), which renumbers edges. The default
  edge.attr.comb would have dropped them silently.
- calculate_roles() pairs each incident edge with its own far endpoint rather
  than zipping incident() against neighbors(); those are sorted by edge id and
  vertex id respectively and need not correspond.
- plot_CCDF() gets a separate continuous code path for strengths, since the
  integer-degree tabulation cannot represent them.

Also fixed while in here:

- network_diffusion_with_pvalues() never called as_netkit_graph(), so the
  data.frame edge list it documents failed with igraph's own error. It was the
  one export missing from test-input-contract.R.
- Same unconditional set.seed(seed) as robustness_analysis() had.
- n_seeds was counted before intersecting with the graph's vertices, so any
  absent seed made every permuted set larger than the real one, inflating the
  null and biasing every p-value.
- RWR's convergence threshold is now relative to sum(abs(f0)) rather than
  absolute, and the unbounded `repeat` has an iteration cap. Both matter now
  that `seed_weights` lets callers start from values of any scale.

New: network_diffusion(seed_weights =) to diffuse from a continuous signal
rather than set membership, and robustness_analysis(removal_strategy =
"strength").

482 -> 558 expectations; one warning, the pre-existing `future` build artifact.
R CMD check --as-cran unchanged at 2 NOTEs, both unavoidable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rics()

netkit described graphs but not their vertices: there was no single call for
per-node centrality, so a ranking had to be stitched together from individual
igraph calls. node_metrics() computes eleven metrics -- degree, strength,
betweenness, closeness, harmonic, eigenvector, pagerank, coreness, local
clustering, Burt's constraint, eccentricity -- and returns the standard
plot/result/graph/method shape.

Because `graph` carries every metric as a vertex attribute, this also makes
robustness_analysis()'s documented "any numeric vertex attribute" removal
strategy usable without extra steps:

  g <- node_metrics(g)$graph
  robustness_analysis(g, removal_strategy = "pagerank")

Design notes:

- `closeness` is excluded from the defaults. igraph averages distance over
  *reachable* vertices only, so on a fragmented graph a node in a small isolated
  component scores higher than an equally well-placed node in the giant
  component -- ranking by closeness puts the periphery on top. `harmonic` has no
  such failure mode because unreachable pairs contribute 1/Inf = 0 rather than
  being dropped from the denominator. Both behaviours are pinned by a test on
  the 5-clique + 4-clique fixture.
- The default plot is a Spearman correlation heatmap, not a ranking. The usual
  mistake with a table like this is to read the metrics as independent evidence
  when betweenness, eigenvector centrality and PageRank often correlate with
  degree above 0.9; the heatmap shows that before the ranking is interpreted.
- `max_nodes` (default 5000, matching summarize_graph_metrics()) skips the O(VE)
  metrics on large graphs, warns, and returns them as NA rather than omitting
  the columns -- so the column set never depends on the graph's size.
- `degree` is always the unweighted count and `strength` the weighted sum, so
  neither column changes meaning with the arguments.

Metrics are pinned to hand-computed values on the 5-node ring (betweenness 1,
harmonic 3, eccentricity 2, clustering 0, pagerank 1/5) and to properties
elsewhere: degree equals the adjacency row sums, coreness never exceeds degree,
pagerank sums to 1, normalization preserves ranking, and unit weights reproduce
the unweighted result.

558 -> 621 expectations. R CMD check --as-cran unchanged at 2 NOTEs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The most common real task in netkit's target domain -- "here is my gene list,
show me how these are connected" -- had no entry point. Five strategies:

- "induced": the plain induced subgraph, the baseline to compare against.
- "neighbors": the order-step neighbourhood, with `max_degree` to exclude
  promiscuous hubs. Without that cap, first-neighbour expansion on a
  hub-dominated network returns most of the network.
- "shortest_paths": the union of all shortest paths between every seed pair.
- "steiner": an approximate minimum Steiner tree (Kou-Markowsky-Berman:
  metric closure, MST of the closure, path expansion, leaf pruning), within
  2 - 2/|terminals| of optimal.
- "diffusion": reuse the existing kernel, keep the top_n scoring vertices.

Returns result/graph/method; no `plot` element, since like plot_Net(),
find_modules() and highlight_nodes() it renders through base plot.igraph.

Two things found by testing rather than by reading:

- The Steiner helper originally returned only the vertex *names* of the tree it
  built, and the caller then took the induced subgraph on them -- which put back
  exactly the cycle-closing edges the spanning-tree step had removed. A method
  named after a tree was returning a subgraph with cycles (8 edges on 7 nodes).
  The helper now hands back the tree itself. The first version of the tree test
  passed only because the induced subgraph happened to be a tree on that one
  fixture; it now runs across three.

- The unit-weight identity property does not hold for the two path-based
  methods, and should not be asserted. igraph uses breadth-first search when no
  weights are given and Dijkstra when they are, and the two break ties between
  equal-cost paths differently, so declaring all-equal weights can yield a
  different near-minimal tree. The test now asserts what is actually well
  defined -- that the pairwise distances agree, that both results are trees
  spanning the terminals, and that their costs are within the method's stated
  approximation bound of each other -- and the behaviour is documented rather
  than papered over.

621 -> 693 expectations. R CMD check --as-cran unchanged at 2 NOTEs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…null

netkit could describe a graph but not say whether any of it was surprising. A
modularity of 0.42 means nothing on its own -- degree-preserving random graphs
routinely reach 0.3 -- and the only statistical statement the package made was a
KS test on two degree vectors.

- null_model(): ensembles under "rewire" (degree-preserving double-edge swaps,
  the default), "configuration" (sample_degseq, falling back from "vl" to
  "configuration.simple" with a warning where no connected realisation exists),
  or "erdos_renyi" (the deliberate weak contrast). Weights are permuted across
  the null's edges so the distribution is preserved and the placement is not.

- metric_significance(): composes directly with summarize_graph_metrics(), so no
  new metric definitions. Reports observed, null mean and sd, z, two-sided
  empirical p and the null's 2.5/97.5 percentiles. p uses (r+1)/(n+1) and so is
  never 0; the method string states the resulting floor, because 20 nulls cannot
  produce evidence at p < 0.05 however large the effect. Metrics the null holds
  fixed (Nodes, Edges, Density) are excluded from automatic selection, since
  their z is undefined, but are reported as NA if requested explicitly.

- small_worldness(): sigma = (C/C_rand)/(L/L_rand). omega is deliberately NOT
  shipped: it needs a lattice reference, igraph has no degree-preserving
  latticisation, and an approximate one would make omega quietly dependent on
  how well that approximation worked.

- network_diffusion_with_pvalues(null = "degree_matched"), now the default.
  Diffusion scores are strongly degree-dependent, so a uniform seed permutation
  compares a real seed set -- usually enriched for well-connected vertices --
  against a null of mostly poorly connected ones, and reports the degree
  difference as significance. On a 150-node scale-free graph seeded with its five
  largest hubs, the uniform null calls 157 nodes significant at p < 0.05 and the
  matched null 134. This changes every p-value the function produces; the package
  is unreleased, so this is the moment to pick the better default.

Getting the matching right took three attempts, and the first two are recorded
in the code because both looked correct:

  quantile bins of degree value -- collapse on a heavy-tailed distribution.
    Most vertices of a scale-free graph have degree 1, so most quantiles
    coincide, unique() folds them together, and the surviving top bin spans
    degree 3 to the largest hub. Mean seed degree 11.2 -> null 5.9 (uniform 1.7).
  quantile bins of degree rank -- equal-sized by construction but still wide.
    Ten bins over 200 vertices is twenty per bin, degrees 1..17 inside the top
    one. No better: 5.2.
  nearest-degree matching -- each seed replaced from the match_pool candidates
    closest to it in degree. Per-seed median absolute degree difference 0.

The residual set-level gap is real and is now warned about rather than assumed
away: the six highest-degree vertices of a scale-free graph have no comparable
vertices to be permuted with, so no permutation null can fully correct for
degree there. That is a property of the graph, not of the sampling.

Two of my own bugs, both caught by tests rather than by reading:
- match_pool = Inf was documented as reducing "exactly" to the uniform null but
  consumed the RNG differently, so it was only equivalent in distribution. It now
  returns the uniform sampler outright.
- shuffle_weights = FALSE left model = "rewire" inheriting the observed `weight`
  attribute bound to edges that had since been rewired -- neither the observed
  weighting nor a declared permutation of it. The nulls are now unambiguously
  unweighted.

693 -> 815 expectations. 21 executable examples in ~1.1s.
R CMD check --as-cran unchanged at 2 NOTEs, both unavoidable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Version 0.0.1.9000 -> 0.1.0.9000. These five commits add five exports and
  make three behaviour changes, so a patch bump would misrepresent them.
- DESCRIPTION Description expanded to cover the new capabilities, with the
  Kou-Markowsky-Berman DOI alongside the existing Guimera-Amaral one.
- NEWS.md gains a real entry with "Breaking changes" first, since the weight
  default, the diffusion null, the robustness RNG fix and the edge_overlap
  redefinition all change results for existing code.
- _pkgdown.yml had no `reference:` section, so all 21 exports landed in one
  ungrouped list. Now grouped into nine topics.
- British spellings normalized to match the declared Language: en-US, and
  inst/WORDLIST extended from 62 to 100 entries. spell_check_package() is clean.

One self-inflicted bug from the spelling pass, caught by the suite: the
search-and-replace rewrote `@importFrom dplyr ... summarise ...` to `summarize`
while the protected call site kept the correct spelling, so the import vanished
and robustness_analysis() failed with "could not find function summarise" in 8
tests. The tag is restored.

815 expectations, 0 failures, 1 warning (the pre-existing `future` build
artifact). R CMD check --as-cran: 2 NOTEs, both unavoidable. Build tarball
carries no dev/, .claude, README.html or cran-comments.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The weight contract is the one place netkit's behaviour is genuinely subtle, and
a parameter description cannot carry it: the point is that the same numbers mean
opposite things depending on a declaration the caller makes, and that is only
convincing worked through.

The first draft of the central comparison was wrong in a way worth recording. It
put the two readings' diameters side by side and called the difference an order
of magnitude -- but one diameter is measured in units of confidence and the other
in units of 1/confidence, so the comparison was meaningless, and the article
would have been committing the error it exists to warn about. It now compares the
share of a crossing path that the bridge accounts for, which is unit-free: 5%
read as a strength, 83% read as a cost, from the identical input.

R CMD check --as-cran still 2 NOTEs; both vignettes rebuild. spell check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README and the tutorial vignette both still described the 0.0.1 feature set,
so none of the five new exports was discoverable from the front door.

- README.Rmd gains a "What it does" summary covering all nine topic areas and a
  short section on the edge-weight contract, plus a worked chaining example.
  Regenerated README.md from it (never hand-edited).
- introduction.Rmd gains three new sections: node-level metrics (3.2), null
  models and metric significance (3.3), and subnetwork extraction (4). The
  subsequent sections are renumbered. It renders in ~5s.
- Fixed the pre-existing \VignetteIndexEntry{introduction} / YAML title
  mismatch that rmarkdown warned about on every render.

Two things caught by the check rather than by reading:

- The first README draft used `g |> node_metrics() |> _$graph` for the chaining
  example. The `_` placeholder needs R >= 4.2 and extraction from it R >= 4.3,
  while DESCRIPTION declares R >= 4.1. Replaced with plain reassignment.
- Linking to the not-yet-published pkgdown article page made
  `R CMD check --as-cran` report a 404 under "possibly invalid URLs". The link
  is removed with a note to restore it; pkgdown::build_site() cannot run in this
  sandbox (it needs to write to ~/Library/Caches), so docs/ remains stale and
  is the one item left outstanding.

815 expectations, 0 failures. R CMD check --as-cran: 2 NOTEs, both unavoidable.
spell check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caught by spell_check_package() after the previous commit; Language is en-US.
'pkgdown' added to inst/WORDLIST. Spell check is clean and R CMD check --as-cran
is unchanged at 2 NOTEs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regenerated from the current sources, so docs/ is no longer stale. Covers the
five new reference pages (node_metrics, extract_subnetwork, null_model,
metric_significance, small_worldness) plus the netkit-weights topic, the new
weighted-networks article, the nine reference groups from _pkgdown.yml, the
rewritten index from README.Rmd, the NEWS entry, and version 0.1.0.9000.

articles/introduction.html picked up the three new tutorial sections
automatically, since it is generated from vignettes/introduction.Rmd.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The link was removed last session because the page did not exist yet and
R CMD check --as-cran reported it under "possibly invalid URLs". The page now
exists in docs/, so the link is correct again -- but it only resolves once these
commits reach origin/main, since GitHub Pages serves this site from main/docs.

Until that push lands, `R CMD check --as-cran` reports the 404 as a line inside
the existing "CRAN incoming feasibility" NOTE. The NOTE count stays at 2; the
line clears on deployment.

815 expectations, 0 failures. Spell check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@agallinat
agallinat merged commit b98ab50 into main Oct 5, 2026
5 checks passed
agallinat added a commit that referenced this pull request Oct 5, 2026
PR #8 merged feat/defect-sweep server-side; the only commit it brings that we
did not already have is 08cb673, which replaced the "Test environments" TODO
in cran-comments.md with the five GitHub Actions platforms, all of which have
now passed. Rewriting that file for 0.1.0 reinstated the TODO, so this merge
takes their side -- the TODO existed precisely until a CI run existed, and one
does.

Also drops the paragraph explaining the "Version contains large components"
note. That note was a consequence of the .9000 development suffix; DESCRIPTION
is now 0.1.0 and --as-cran no longer reports it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant