Author: Giona Casiraghi
Date: 2026-02-04

# ghypernet Tutorial

#### Giona Casiraghi

#### 2026-02-04

This is a short tutorial to show how to employ the `ghypernet` R package for the estimation of gHypEG. In the following, we use Zachary’s Karate Club dataset as baseline. The data is provided within this package and can be simply loaded. It corresponds to a weighted adjacency matrix whose entries report the number of interactions between individuals. The graph specified by the adjacency matrix is undirected and does not have self-loops. For this reason, we specify the two parameters `directed=FALSE` and `selfloops=FALSE`.

    library(ghypernet)

    data("adj_karate")
    directed <- FALSE
    selfloops <- FALSE
    print(adj_karate[1:10,1:10])
    #>          Mr Hi Actor 2 Actor 3 Actor 4 Actor 5 Actor 6 Actor 7 Actor 8 Actor 9
    #> Mr Hi        0       4       5       3       3       3       3       2       2
    #> Actor 2      4       0       6       3       0       0       0       4       0
    #> Actor 3      5       6       0       3       0       0       0       4       5
    #> Actor 4      3       3       3       0       0       0       0       3       0
    #> Actor 5      3       0       0       0       0       0       2       0       0
    #> Actor 6      3       0       0       0       0       0       5       0       0
    #> Actor 7      3       0       0       0       2       5       0       0       0
    #> Actor 8      2       4       4       3       0       0       0       0       0
    #> Actor 9      2       0       5       0       0       0       0       0       0
    #> Actor 10     0       0       1       0       0       0       0       0       0
    #>          Actor 10
    #> Mr Hi           0
    #> Actor 2         0
    #> Actor 3         1
    #> Actor 4         0
    #> Actor 5         0
    #> Actor 6         0
    #> Actor 7         0
    #> Actor 8         0
    #> Actor 9         0
    #> Actor 10        0

# Fitting a gHypEG Model

GHypEG Models can be fitted using the general function `ghype`. However, we provide some default functions to fit specific models. These functions take an adjacency matrix (not sparse) as main argument, or an `igraph` graph object. They further allow to specify whether the model should be directed or not, and whether it should have selfloops or not.

The function `regularm` allows to fit the simplest possible model that can be formulated with gHypEGs: gnp-like graph where only the average degree is preserved.

    (regularmodel <- regularm(graph = adj_karate, directed = directed, selfloops = selfloops))
    #> Call:
    #> ghype.matrix(graph = graph, directed = directed, selfloops = selfloops,
    #>     unbiased = TRUE, regular = TRUE)
    #> ghype undirected , no selfloops
    #> 34 vertices, 231 edges
    #> Loglikelihood:
    #> -584.7139
    #> df: 1

The function `scm` allows to fit the soft-configuration model, where all degrees are preserved.

    (confmodel <- scm(graph = adj_karate, directed = directed, selfloops = selfloops))
    #> Call:
    #> ghype.matrix(graph = graph, directed = directed, selfloops = selfloops,
    #>     unbiased = TRUE, regular = FALSE)
    #> ghype undirected , no selfloops
    #> 34 vertices, 231 edges
    #> Loglikelihood:
    #> -434.5449
    #> df: 34

From the models, we can generate random realisations that can be used, e.g., as null-models to test hypothesis about the data. This can be achieved by means of the function `rghype`, specifying the number of realisations to take.

    random_graphs_rm <- rghype(nsamples = 10, model = regularmodel, seed = 123)
    random_graphs_scm <- rghype(nsamples = 10, model = confmodel, seed = 123)

Finally, we can perform model selection and hypothesis testing by comparing statistics of two models. We can follow two different approaches, comparing information scores such as AIC, or performing a likelihood ratio test. One simple question we can ask, for example, is whether the degree sequence of the Karate Club can be simply explained by a regular model, or there is the need to use a configuration model. AIC scores can be obtained using the standard R function `AIC`. Likelihood-ratio tests are performed using the function `lr.test`. However, we provide some wrappers for the function `lr.test` for some commonly done tests like the one above.

Comparing AIC scores for the regular model and the configuration model yields the following result:

    AIC(regularmodel)
    #> [1] 1171.428
    AIC(confmodel)
    #> [1] 937.0897

    # difference in AICs, high value means more complex model is better
    AIC(regularmodel) - AIC(confmodel)
    #> [1] 234.338

This shows that the there is strong evidence for the employing the configuration model, because the degree sequence of the graph strongly deviates from the regular one.

To increase confidence in this result, we can compare the result above with that obtained from random data generated from the regular model.

    # Generate regular models and configuration models for random graphs
    regularmodels <- lapply(X = random_graphs_rm, FUN = regularm, directed=directed, selfloops=selfloops)
    confmodels <- lapply(X = random_graphs_rm, FUN = scm, directed=directed, selfloops=selfloops)
    # Compute AICs
    AIC_regularmodels <- sapply(X = regularmodels,FUN = AIC)
    AIC_confmodels <- sapply(X = confmodels,FUN = AIC)
    # differences in AIC, high value means more complex model is better
    AIC_regularmodels - AIC_confmodels
    #>  [1] -30.38597 -37.51925 -22.60637 -31.82815 -43.41602 -23.19164 -37.91881
    #>  [8] -31.03509 -31.14738 -40.52865

As can be seen by the experiment above, in the case of random graphs generated from the regular model, the AIC of the configuration model is higher than that of the regular model, confirming that the added complexity is not justified.

The (empirical) likelihood-ratio test gives a similar result, with the benefit of providing a p-value. The function `conf.test` provides a simple way to perform the test without the need of computing the models.

    conf.test(graph = adj_karate, directed = directed, selfloops = selfloops, seed=123)
    #>
    #>  LR test -- gnp vs CM
    #>
    #> data:
    #> lr = 299.79, df = 33, p-value < 2.2e-16
    #> alternative hypothesis: one.sided
    #> 95 percent confidence interval:
    #>  19.33927 52.92658

Similarly to what done above, we can perform the test using the random graphs. Now we expect large p-values.

    tests <- lapply(X = random_graphs_rm, FUN = conf.test, directed = directed, selfloops = selfloops, seed=123)
    sapply(X = tests, FUN = function(x) x$p.value)
    #>  [1] 0.3984114 0.7271246 0.1410278 0.4624926 0.9241292 0.1545082 0.7435278
    #>  [8] 0.4265697 0.4325023 0.8442153

# Block-Constrained Configuration Model

The next model that can be estimated is the block constrained configuration model. The Karate Club has a well-known partitioning into two communities that can be loaded from the package. We fit a bccm using the `bccm` function, specifying the vertex labels.

    data("vertexlabels")
    (blockmodel <- bccm(adj = adj_karate, labels = vertexlabels, directed = directed, selfloops = selfloops))
    #> Call:
    #> bccm(adj = adj_karate, labels = vertexlabels, directed = directed,
    #>     selfloops = selfloops)
    #> block ghype undirected , no selfloops
    #> 34 vertices, 231 edges
    #> Loglikelihood:
    #> -336.8385
    #> df: 36
    #>
    #> Coefficients:
    #>         Estimate    Std.Err t value    Pr(>t)
    #> 1<->1 1.00000000 0.00000000     Inf < 2.2e-16 ***
    #> 1<->2 0.09044494 0.00021692  416.95 < 2.2e-16 ***
    #> 2<->2 0.91049902 0.00040434 2251.79 < 2.2e-16 ***
    #> ---
    #> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
    print(blockmodel$blockOmega)
    #>            1          2
    #> 1 1.00000000 0.09044494
    #> 2 0.09044494 0.91049902

By default, the function fits a ‘full’ block structure, where every parameter for in- out-blocks relations are different. In this case this corresponds to three parameters: one for block 1, one for block 2, one for the edges between the two blocks. However, in some cases we see that the parameters of in-blocks relations are similar to each other, as can be seen above looking at the diagonal entries of the block matrix. In this case, a full block structure may overfit the data, while a simpler model could be better. This can be fitted setting the parameter `homophily=TRUE`. This corresponds to a model where there are only two parameters, one for in-block edges, one for out-block edges, irrespectively of the number of blocks. The result is shown below.

    (blockmodel_2 <- bccm(adj = adj_karate, labels = vertexlabels, directed = directed, selfloops = selfloops, homophily = TRUE))
    #> Call:
    #> bccm(adj = adj_karate, labels = vertexlabels, directed = directed,
    #>     selfloops = selfloops, homophily = TRUE)
    #> block ghype undirected , no selfloops
    #> 34 vertices, 231 edges
    #> Loglikelihood:
    #> -337.0666
    #> df: 35
    #>
    #> Coefficients:
    #>             Estimate    Std.Err t value    Pr(>t)
    #> homologue 1.00000000 0.00000000     Inf < 2.2e-16 ***
    #> hetero    0.09512422 0.00020628  461.14 < 2.2e-16 ***
    #> ---
    #> Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

The first test we need to perform is whether either of the model just fitted significantly improve the fit to the data. We do so by performing a likelihood ratio test between the configuration model and the bccms.

    lr.test(nullmodel = confmodel, altmodel = blockmodel, seed = 123)
    #>
    #>  LR test
    #>
    #> data:
    #> lr = 195.41, df = 2, p-value < 2.2e-16
    #> alternative hypothesis: one.sided
    #> 95 percent confidence interval:
    #>  0.001016833 4.966303402
    lr.test(nullmodel = confmodel, altmodel = blockmodel_2, seed = 123)
    #>
    #>  LR test
    #>
    #> data:
    #> lr = 194.96, df = 1, p-value < 2.2e-16
    #> alternative hypothesis: one.sided
    #> 95 percent confidence interval:
    #>  0.000860411 4.910630511

Unsurprisingly, the test gives low p-values, confirming the presence of a block structure. Again, we can perform a similar analysis using AIC:

    AIC(confmodel)
    #> [1] 937.0897
    AIC(blockmodel_2)
    #> [1] 744.1332
    AIC(blockmodel)
    #> [1] 745.677

From this, we note that while specifying the full bccm gives an improvement in the score compared to the two parameters model, such improvement is relatively small, and appears to not justify the increased complexity. We can verify again this with a likelihood-ratio test:

    lr.test(nullmodel = blockmodel_2, altmodel = blockmodel, seed=123)
    #>
    #>  LR test
    #>
    #> data:
    #> lr = 0.45622, df = 1, p-value = 0.7645
    #> alternative hypothesis: one.sided
    #> 95 percent confidence interval:
    #>  0.03722328 6.94467855

The result shows that the added complexity of the second model is not justified by the data. We investigate this further by comparing the results with what obtained from random variations.

    # First generate random sample from blockmodel
    random_graphs_bccm2 <- rghype(nsamples = 100, model = blockmodel_2, seed = 123)
    # Generate the two models for random graphs
    blockmodels <- lapply(X = random_graphs_bccm2, FUN = bccm, labels = vertexlabels, directed=directed, selfloops=selfloops)
    blockmodel_2s <- lapply(X = random_graphs_bccm2, FUN = bccm, labels = vertexlabels, directed=directed, selfloops=selfloops, homophily = TRUE)
    # Compute AICs
    AIC_blockmodels <- sapply(X = blockmodels, FUN = AIC)
    AIC_blockmodel_2s <- sapply(X = blockmodel_2s, FUN = AIC)
    # mean difference in AIC, high value means more complex model is better
    summary(AIC_blockmodel_2s - AIC_blockmodels)
    #>    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.
    #> -2.0000 -1.5366 -0.6637 -0.1704  0.6268  8.3348

This confirms the fact that the asymmetry in the two blocks can ascribed to random variations.

# Goodness of Fit

Finally, our framework allows to evaluate the goodness-of-fit of model to data. Similarly to a multinomial goodness-of-fit, we perform a test of the chosen against the maximally complex model that can be formulated. This model provides the baseline against which comparing simpler models in terms of their goodness-of-fit. The ‘full model’ is specified such that the observed graph is the expected one. It is fitted using the function `ghype` with the flag `unbiased=FALSE`. The likelihood ratio test can be performed either manually or using the function `gof.test`.

    fullmodel <- ghype(graph = adj_karate, directed = directed, selfloops = selfloops, unbiased = FALSE)

    lr.test(nullmodel = blockmodel_2, altmodel = fullmodel, seed = 123)
    #>
    #>  LR test
    #>
    #> data:
    #> lr = 454.89, df = 559, p-value = 7.719e-14
    #> alternative hypothesis: one.sided
    #> 95 percent confidence interval:
    #>  270.4443 340.7790
    gof.test(model = blockmodel_2, seed = 123)
    #>
    #>  LR test -- GOF
    #>
    #> data:
    #> lr = 454.89, df = 559, p-value = 7.719e-14
    #> alternative hypothesis: one.sided
    #> 95 percent confidence interval:
    #>  270.4443 340.7790

The reason for this result is the fact that, although the bccm is a good fit for the empirical graph, the latter is characterised by some particular structures that are not encoded by the bccm. For example, the empirical graph is characterised by few ‘bridges’ between the two communities, managed by relatively low degree vertices. Instead, the bccm assumes that all vertices connect weakly the two communities, with an amount of edges proportional to the degree.