Weights, Units, and Transforms#
Every weighted method makes an assumption about what an edge weight is: a
count of events, a share of a node’s strength, a distance, or just a value
that can be ranked. This guide explains how the results change when the
weights are rescaled or transformed, how to transform weights with
transform_weights(), how to set the count unit of the
count-based null models, and how to relax the null model of the disparity
filter.
Transforming weights#
transform_weights() returns a copy of a graph with
transformed edge weights. It takes a plain function, applied to each weight,
or any object with a scikit-learn style fit_transform method, which is
called once with the weights as a column. scikit-learn is not a dependency:
any object with that method works, and a plain function needs neither NumPy
nor scikit-learn.
import math
import networkx as nx
import nx_backbone as nb
G = nx.les_miserables_graph()
H = nb.transform_weights(G, math.log1p) # replaces "weight"
H = nb.transform_weights(G, math.sqrt, out="w") # keeps "weight", adds "w"
# Any object with fit_transform, for example from scikit-learn:
from sklearn.preprocessing import PowerTransformer
boxcox = PowerTransformer(method="box-cox", standardize=False)
H = nb.transform_weights(G, boxcox)
The returned graph keeps every node’s neighbor order, so methods that break
weight ties by neighbor order give the same result on an identity transform.
The transform is recorded in H.graph["weight_transforms"], and
describe_backbone() reports it in the methods text.
The function raises an error when the output is not finite or is the same for
every edge although the weights differ (scikit-learn’s Normalizer scales
each row, so it maps a column of weights to all ones). It warns when the
transform does not preserve the order of the weights, or when it maps
different weights to the same value: both change the methods that only use
ranks. It does not check that the new weights are positive; the statistical
scorers reject non-positive weights themselves. Centering or standardizing
transforms (StandardScaler, PowerTransformer with its default
standardize=True, QuantileTransformer with a normal output) produce
negative weights.
A transform changes the hypothesis. The disparity filter applied to
log1p(w) tests whether the shares of the log-weights are uniform, not
whether the shares of the weights are. That can be a deliberate modeling
choice, for example log-flows in gravity-type data, but it is a different
test, and it should be reported as such.
Which methods a transform changes#
The table lists, for each weighted method, whether its scores and backbone
stay the same when every weight is multiplied by a constant \(c > 0\), and
when every weight is replaced by \(f(w)\) for a strictly increasing
function \(f\) such as a power, log, Box-Cox, or quantile transform.
Every entry is checked on Les Misérables by
tests/test_weight_transforms.py (rescaling by 4; \(\sqrt{w}\),
\(w^2\) and \(\log(1 + w)\) for increasing transforms), which also
checks that this table matches the tests.
Method |
Rescaling |
Increasing transform |
Why |
|---|---|---|---|
yes |
yes |
Uses only the order of each node’s weights. |
|
yes |
yes |
Keeps each node’s heaviest links. |
|
yes |
yes |
Keeps the heaviest fraction of all edges. |
|
yes |
yes |
Keeps each node’s heaviest link. |
|
yes |
yes |
A maximum spanning tree depends only on the order of the weights. |
|
yes |
yes |
Adds edges in weight order. |
|
yes |
yes |
Minimax paths depend only on the order of the weights. |
|
|
yes |
no |
Tests the shares \(w/s\). |
yes |
no |
Fits each node’s weight shares. |
|
|
yes |
no |
The large-strength approximation depends on \(w/s\) only. |
yes |
no |
With the default |
|
yes |
no |
Row and column scaling absorbs a constant factor. |
|
yes |
no |
Shortest paths on distances \(1/w\): a factor rescales every path alike, a nonlinear transform does not. |
|
yes |
no |
Shortest-path trees on distances \(1/w\). |
|
yes |
no |
Shortest paths on distances \(1/w\). |
|
yes |
no |
Shortest-path involvement on distances \(1/w\). |
|
yes |
no |
Weighted modularity uses weights relative to the total weight. |
|
no |
no |
Weights are counts of unit edges; set |
|
no |
no |
Weights are integer counts; set |
|
no |
no |
Weights are counts of interactions; set |
|
|
no |
no |
Weights are counts of urn draws. |
no |
no |
The h-index compares weights with counts of edges. |
|
|
no |
no |
Mixes \(A^2\) and \(\varepsilon A^3\), whose ratio changes with scale. |
Methods that ignore weights, such as
node_degree_filter(), the proximity indices without
weight=, sparsify(),
lspar(), and local_degree(),
are unaffected by any transform.
global_threshold_filter() changes with the weights
but in step with them: thresholding \(f(w)\) at \(f(t)\) keeps the
same edges as thresholding \(w\) at \(t\).
Some transforms are best avoided:
Before the count models (MLF, ECM, noise-corrected, Pólya with integer weights): a continuous transform destroys the meaning of a weight as a number of events, and binning (
KBinsDiscretizer) gives ordinal bin labels, not counts. Useunit=(below) for a change of unit.Rank transforms before share-based methods: after a uniform
QuantileTransformerthe disparity filter tests the shares of ranks.lans_filter()is the method for ranks.
Count units#
marginal_likelihood_filter(),
ecm_filter(), and
noise_corrected_filter() treat each weight as a
number of events, so their p-values depend on the unit the weights are
recorded in. Their unit= argument sets the weight of one event: the
models use the counts weight / unit (rounded to integers by MLF and ECM).
The default, unit=1, uses the weights themselves, as before. On Les
Misérables, unit=0.1 (each co-appearance counted as ten events) keeps 215
instead of 126 edges for MLF at alpha = 0.05, 89 instead of 72 for the ECM,
and 215 instead of 126 for the noise-corrected filter.
The three scorers warn when weight / unit is not an integer count. MLF
rounds a count below 0.5 to 0 (the edge then gets p-value 1), and the ECM
raises a count below 1.5 to 1.
# Durations in hours, where one event is a quarter of an hour:
H = nb.marginal_likelihood_filter(G, unit=0.25)
The disparity filter’s null model#
The disparity filter (Serrano et al., 2009) assumes that a node of degree
\(k\) splits its strength at \(k - 1\) uniformly random points, so
that a single weight share \(x = w/s\) is Beta(1, \(k - 1\))
distributed. This is exact when the node’s weights are independent
exponential variables, and wrong otherwise: weights that are more
heterogeneous than exponential make the filter reject too often, and more even
weights too rarely (tests/test_calibration.py checks both).
disparity_filter(G, a=...) relaxes the assumption to a one-parameter
family. The shares are symmetric Dirichlet with concentration \(1/a\), so
a single share is Beta(\(1/a\), \((k-1)/a\)); this is exact for
independent Gamma(\(1/a\)) weights of any scale. a=1 is the original
filter (computed exactly as before, without SciPy), a > 1 tolerates more
heterogeneity, and a="ml" fits a by maximum likelihood and stores it in
H.graph["disparity_a"]:
H = nb.disparity_filter(G, a="ml")
H.graph["disparity_a"] # 0.525
nb.threshold_filter(H, "disparity_pvalue", 0.05).number_of_edges() # 19
Les Misérables’ shares are more even than the uniform null expects
(\(\hat a < 1\)), so the fitted filter keeps 19 edges where the original
keeps 9. The parameter has the same meaning as the reinforcement parameter of
polya_filter(): with the shares held fixed, the
Pólya filter’s beta-binomial null converges to this Beta distribution as the
node strength grows. Only this symmetric family is offered, because the links
of a node are exchangeable under the null, so every share has mean
\(1/k\).
The calibration tests (tests/test_calibration.py) simulate weights from
each null model and check that the p-value of a single endpoint is uniform.
The edge p-value of the disparity filter, and of the other two-endpoint
filters, is the smaller of its two endpoint p-values, which is not
calibrated even when the null model holds: on 8-regular graphs with
independent exponential weights, about 8% of the edges fall below 0.05.
Not offered#
Fitting an arbitrary distribution to the weights and testing each weight against it. With one distribution fitted to all weights, the p-value decreases with the weight, so the backbone is a global weight threshold (
global_threshold_filter()) that ignores degrees and strengths. With a distribution fitted to each node’s few weights, the fit reuses the weight being tested.Arbitrary share distributions in the disparity filter, such as a Beta(\(c\), \(d\)) with \(d \neq (k-1)c\): shares of exchangeable links must have mean \(1/k\).
Still open (issue #55): a continuous-weight ECM, an overdispersed (beta-binomial) marginal likelihood filter, and user-supplied weight distributions for GloSS.