Latent Two-Sample Hypothesis Testing
Contents
7.1. Latent Two-Sample Hypothesis Testing#
We have learned a lot thus far about network statistics, and now we want to apply some of that knowledge to doing two-sample network testing. Imagine you are part of an alien race called the Moops. You live in harmony with another race, called the Moors, that look very similar to you, on the planet Zinthar. The evil overlord Zelu takes a random number of Moops and Moors and puts them on an island. You want to find your fellow Moops, but the Moops and Moors look very similar to each other. However, you guess that, perhaps, if you were able to look at a network representing the brain connections for a Moop and another network representing the brain connections for a Moor, you think that there might be a difference between the two. What do you do?
To make this a little bit more concrete, let’s develop our example. The Moops and Moors each have brains with
from graspologic.simulations import sbm
ns = [40, 40]
BMoor = [[0.4, 0.1], [0.1, 0.4]]
AMoor = sbm(ns, BMoor, directed=False, loops=False)
zvec = ["1" for i in range(0, ns[0])] + ["2" for i in range(0, ns[1])]
Click to show
from graphbook_code import draw_multiplot
draw_multiplot(AMoor, labels=zvec, title="Moor Adjacency Matrix");

BMoop = [[0.6, 0.2], [0.2, 0.6]]
AMoop = sbm(ns, BMoop, directed=False, loops=False)
Click to show
draw_multiplot(AMoop, labels=zvec, title="Moop Adjacency Matrix");

7.1.1. Two-sample tests, a quick refresher#
This problem is another iteration of a question you encountered in the last section when testing for differences between groups of edges from Section 6.2, called the two-sample test. You have two samples of data: a network from a Moop and a network from a Moor, and you want to characterize whether these two networks are different. For our purposes, you will call the Moop network
Since
This is just like how, in our coin flip example you learned about previously, you don’t look for differences in the coins themselves, but rather, you look for differences in the probabilities that each coin lands on heads or tails. You construct hypotheses about the probabilities, not the coin. This is because the coins are the same (they both have a heads and tails side, all of the coin flips are performed without regard for the outcomes of other coin flips, so on and so forth), other than the fact that they land on heads and tails at different rates. This rate, the underlying probability, is therefore the element of the random coin that you want to hone in on to test whether they are different. Remember, you made a null hypothesis that there was no difference between the probabilities coins,
7.1.1.1. Two-sample tests and Random Networks#
In this example, however, we’re going to go a slightly different direction. We’re going to describe
In this case, you want to test whether
7.1.1.2. Assuming a statistical model#
To test whether the probability matrices are different, we’re going to make the assumption that
Unfortunately, this assumption is a close, but not quite correct. Remember back in Section 5 we introduced the idea of a rotation matrix in Section 5.3.6.1 with respect to the non-identifiability problem. As it turns out, for a rotation matrix
Even if
Which shows that the probability matrices would still actually be the same!
What this means for you is that you can’t just compare the latent position matrices, but rather, you have to compare the latent position matrices for any possible rotation matrix! Stated another way, we would say that
7.1.2. Latent position test#
So, now we are ready to get to implementing your idea. You have two random networks,
To do this, the first step is to figure out whether
The term
Basically, the idea is that if
Next, you need to figure out how to actually use this to implement a statitical test
Next, you do your best to find a rotation of
from scipy.linalg import orthogonal_procrustes
from graspologic.models import RDPGEstimator
import numpy as np
d = 2
# estimate latent positions for Moop and Moor networks
XMoop = RDPGEstimator(n_components=d).fit(AMoop).latent_
XMoor = RDPGEstimator(n_components=d).fit(AMoor).latent_
# estimate best possible rotation of XMoor to XMoop by
# solving orthogonal procrustes problem
W = orthogonal_procrustes(XMoop, XMoor)[0]
observed_norm = np.linalg.norm(XMoop - XMoor @ W)
Click to show
from matplotlib import pyplot as plt
import seaborn as sns
fig, ax = plt.subplots(1, 1, figsize=(10, 4))
ax.set_title("")
ax.set_ylabel("")
ax.axvline(observed_norm, color="red")
ax.text(1.05*observed_norm , .5, "$||\\hat X^{(p)} - \\hat X^{(r)}||_F$", color="red");
ax.set_xlim([0, 1.4*observed_norm])
ax.set_xlabel("Frobenius norm of difference");

Relative what, exactly?
7.1.2.1. Generating a null distribution via parametric bootstrapping#
In an ideal world, you would be able to characterize how far apart the estimates
So what you do is the next best thing. You instead use
from graspologic.simulations import rdpg
def generate_synthetic_networks(X):
"""
A function which generates two synthetic networks with
same latent position matrix X.
"""
A1 = rdpg(X, directed=False, loops=False)
A2 = rdpg(X, directed=False, loops=False)
return A1, A2
A1, A2 = generate_synthetic_networks(XMoop)
You then estimate the latent positions
def compute_latent(A, d):
"""
A function which returns the latent position estimate
for an adjacency matrix A.
"""
return RDPGEstimator(n_components=d).fit(A).latent_
X1 = compute_latent(A1, d)
X2 = compute_latent(A2, d)
Finally, you compare
def compute_norm_orth_proc(A, B):
"""
A function which finds the best rotation of B onto A,
and then computes and returns the norm.
"""
R = orthogonal_procrustes(A, B)[0]
return np.linalg.norm(A - B @ R)
norm_null = compute_norm_orth_proc(X1, X2)
Click to show
fig, ax = plt.subplots(1, 1, figsize=(10, 4))
ax.set_title("")
ax.set_ylabel("")
ax.axvline(observed_norm, color="red")
ax.text(observed_norm + .1, .2, "$||\\hat X^{(p)} - \\hat X^{(r)}||_F$", color="red")
ax.axvline(norm_null, color="blue")
ax.text(norm_null -.5, .2, "$||\\hat X^{(1)} - \\hat X^{(2)}||_F$", color="blue")
ax.set_xlim([0, 1.4*observed_norm])
ax.set_xlabel("Frobenius norm of difference");

You keep repeating this process again and again, and over time, you gradually get some idea of what
def parametric_resample(A1, A2, d, nreps=100):
"""
A function to generate samples of the null distribution under H0
using parametric resampling.
"""
null_norms = np.zeros(nreps)
for i in range(0, nreps):
A1, A2 = generate_synthetic_networks(XMoop)
X1 = compute_latent(A1, d)
X2 = compute_latent(A2, d)
null_norms[i] = compute_norm_orth_proc(X1, X2)
return null_norms
nreps = 100
null_norms = parametric_resample(AMoop, AMoor, 2, nreps=nreps)
Click to show
from pandas import DataFrame
samples = [{"Sample": i, "Norm": x} for i, x in enumerate(null_norms)]
null_df = DataFrame(samples)
fig, ax = plt.subplots(1, 1, figsize=(10, 4))
sns.histplot(null_df, x="Norm", stat="probability", ax=ax)
ax.axvline(observed_norm, color="red")
ax.set_xlim([0, 1.4*observed_norm])
ax.set_ylabel("Fraction of samples")
ax.text(np.median(null_norms) - .6, .2, "$||\\hat X^{(1)} - \\hat X^{(2)}||_F$", color="blue")
ax.text(observed_norm + .1, .2, "$||\\hat X^{(p)} - \\hat X^{(r)}||_F$", color="red");

What you see is that
pval = ((null_norms >= observed_norm).sum() + 1)/(nreps + 1)
print("estimate of p-value: {:.3f}".format(pval))
estimate of p-value: 0.010
We then repeat this process, but we use
This is called the latent position test, and it is implemented directly by graspologic
. Note that the n_bootstraps
to a higher value will tend to yield more stable
from graspologic.inference import latent_position_test
nreps = 1000 # the number of times to repeat the process of estimating X1hat and X2hat
lpt_moop2moor = latent_position_test(AMoop, AMoor, n_bootstraps = nreps, n_components=d)
print("estimate of p-value: {:.5f}".format(lpt_moop2moor[1]))
estimate of p-value: 0.00100
The
What if we had another network from a Moop, and we compared the network of our first Moop to our new Moop? We can generate a new comparison:
# generate a new Moor network with the same block matrix, and hence,
# the same latent position matrix as AMoor
np.random.seed(1234)
AMoor2 = sbm(ns, BMoor)
lpt_moor2moor = latent_position_test(AMoor, AMoor2, n_bootstraps=nreps, n_components=d)
print("estimate of p-value: {:.5f}".format(lpt_moor2moor[1]))
estimate of p-value: 0.19580
The
7.1.3. Latent distribution test#
To motivate a latent distribution test, we’re going to return to yet another coin example. Imagine that you and a friend do not each have a single coin like the examples that you have seen so far, but you each have a container of
ncoins = 300 # the number of coins in each container
# the probabilities of your coins landing on heads
np.random.seed(1234)
piy = np.random.beta(a=3, b=5, size=ncoins)
# the probabilities of your friend's coins of landing on heads
pif = np.random.beta(a=5, b=3, size=ncoins)
Click to show
coindf = DataFrame({"Bucket": ["Yours" for i in range(0, ncoins)] + ["Friend" for i in range(0, ncoins)],
"Probability": np.append(piy, pif)})
fig, ax = plt.subplots(1,1, figsize=(10, 4))
palette={"Yours" : "blue", "Friend": "red"}
sns.histplot(data=coindf, x="Probability", bins=10, hue="Bucket",
palette=palette, ax=ax)
ax.set_xlabel("Probability that coin lands on heads")
ax.set_ylabel("Number of coins")
ax.axvline(3/8, color="blue")
ax.text(0.17, y=60, s="Average prob.\n your coins", color="blue")
ax.axvline(5/8, color="red")
ax.text(0.45, y=60, s="Average prob.\n friend coins", color="red");

You grab each grab a single coin at random from your containers, and flip them and observe whether they land on heads or tails, the outcomes
Much the same, you could assume that not only are the latent positions for Moors and Moops are different, but these might be random too, just like the probabilities of the coins in the example you just learned about. What we mean when we say that the latent position matrices are random, we mean that the latent positions for each node, the vectors
You won’t need to go too in-depth here, but the basic idea is that the latent position vectors for each node,
Like we did before, we will make null and alternative hypotheses for these situations. Your null hypothesis is going to be that
The general idea is that we will assume as little as possible about the distributions for the latent positions. Two good ways to do this are using an approach called distance correlation or multiscale generalized correlation (MGC). You can do these very easily using graspologic
, through the latent_distribution_test
function:
from graspologic.inference import latent_distribution_test
nreps = 1000
approach = 'dcorr' # the strategy for the latent distribution test
ldt_dcorr = latent_distribution_test(AMoop, AMoor, test=approach, metric="euclidean",
n_bootstraps=nreps)
The relevant things to look at for the latent distribution test are the
print("p-value of H0 against HA: {:.4f}".format(ldt_dcorr[1]))
p-value of H0 against HA: 0.0719
In this case, the distance correlation/MGC approaches produce a test statistic which has a relatively similar interpretation to the one which we used before (the norm of the difference of the estimated latent positions). When these test statistics are relatively large, the two distributions are different. Again, we figure out what we mean by “relative” by using resampling techniques (in this case, non-parametric permutation testing), and we produce estimates of what the test statistic would look like if the distributions were the same and compare to the one we saw in our samples:
Click to show
fig, ax = plt.subplots(1, 1, figsize=(10, 4))
null_distn = DataFrame({"Null dcorrs" : ldt_dcorr[2]["null_distribution"]})
sns.histplot(null_distn, x="Null dcorrs", bins=30, ax=ax)
ax.set_xlabel("Test statistic")
ax.axvline(ldt_dcorr[0], color="red")
ax.text(ldt_dcorr[0] + .002, y=100, color="red", s="Observed")
ax.text(np.quantile(ldt_dcorr[2]["null_distribution"], .75), y=100, color="blue", s="if the distributions\n were equal")
ax.set_title("Comparison of observed DCorr test statistic to null test statistics");

For more details on these approaches, check out [3] and [4].
7.1.4. The Latent distribution test is more conservative than the latent position test#
The latent distribution test is called non-parametric because it does not make the assumption that the networks are RDPGs with fixed latent position matrices like we did in the latent position test. In this sense, the latent distribution test tends to be a little bit more general than the latent position test, and will tend to be more conservative. In statistics, we like conservative tests, because it gives us more confidence that our assumptions did not impact our conclusions. When we obtain results in favor of
Moreover, the latent position test assumes that the two networks have node matching: node
7.1.5. References#
The appendix section Section 12.2 discusses the implications of latent distribution testing with respect to spectral embeddings.
- 1
John C. Gower and Garmt B. Dijksterhuis. Procrustes Problems (Oxford Statistical Science Series, 30). Oxford University Press, Oxford, England, UK, April 2004. ISBN 978-0-19851058-1. URL: https://www.amazon.com/Procrustes-Probn2017ms-Oxford-Statistical-Science/dp/0198510586.
- 2
Minh Tang, Avanti Athreya, Daniel L. Sussman, Vince Lyzinski, Youngser Park, and Carey E. Priebe. A Semiparametric Two-Sample Hypothesis Testing Problem for Random Graphs. J. Comput. Graph. Stat., 26(2):344–354, April 2017. doi:10.1080/10618600.2016.1193505.
- 3
Minh Tang, Avanti Athreya, Daniel L. Sussman, Vince Lyzinski, and Carey E. Priebe. A nonparametric two-sample hypothesis testing problem for random graphs. Bernoulli, 23(3):1599–1630, August 2017. doi:10.3150/15-BEJ789.
- 4
A. Alyakin, Joshua Agterberg, Hayden S. Helm, and C. Priebe. Correcting a Nonparametric Two-sample Graph Hypothesis Test for Graphs with Different Numbers of Vertices. arXiv: Methodology, 2020. URL: https://www.semanticscholar.org/paper/Correcting-a-Nonparametric-Two-sample-Graph-Test-of-Alyakin-Agterberg/c8265eba85e51020be7af24debd467accf2f073b.