--- title: "Stochastic Block Models with covariates on nodes" author: "S. Donnet" date: today format: html: toc: true toc-depth: 3 html-math-method: mathml # rendered by the browsers, even when the vignette is self-contained bibliography: references.bib link-citations: true knitr: opts_chunk: collapse: true comment: "#>" fig.width: 5 fig.height: 5 vignette: > %\VignetteIndexEntry{Stochastic Block Models with covariates on nodes} %\VignetteEngine{quarto::html} %\VignetteEncoding{UTF-8} --- ## 1. Introduction In a standard Stochastic Block Model (SBM), the nodes are assigned to blocks independently, with the same prior probabilities $\pi = (\pi_1, \dots, \pi_Q)$ for all nodes. When some characteristics of the nodes are known (age, size, species traits...), it is natural to let these characteristics drive the probability of belonging to each block. This vignette shows how to: 1. simulate and fit simple and bipartite SBM in which the block memberships depend on **covariates on nodes**; 2. check that taking the covariates into account can improve the clustering, especially when the network is noisy; 3. use the Integrated Classification Likelihood (ICL) to decide whether a covariate has an effect on the clustering. The inference relies on a variational EM algorithm implemented in the package **blockmodels** [@blockmodels]. ```{r setup, message = FALSE, warning = FALSE} library(sbm) library(ggplot2) library(knitr) theme_set(theme_bw()) ``` We use the Adjusted Rand Index (ARI) from package **aricode** to compare an estimated clustering with the true one (ARI equals $1$ for a perfect recovery, and is close to $0$ for a random clustering). ```{r ari} ARI <- aricode::ARI ``` ```{r text-helpers, include = FALSE} # Helpers for the comments of the results: the text is generated from the # results, so that it stays consistent with a new simulation. fmt <- function(x, d = 2) format(round(x, d), nsmall = d) quality <- function(a) { if (a > 0.95) "nearly perfectly" else if (a > 0.8) "well" else if (a > 0.5) "partially" else "poorly" } compare <- function(gain, cost) { if (gain > 2 * cost) "much more than" else if (gain > cost) "more than" else "less than" } ``` ```{r precomputed-fits, include = FALSE} # The fits are long: they are precomputed by dev/precompute_SBM_nodes_covariates.R # and loaded here. If the file is missing, all the fits are run in the vignette. fitsFile <- "SBM_nodes_covariates_fits.rda" runFits <- !file.exists(fitsFile) if (!runFits) load(fitsFile) ``` ## 2. Simple SBM with covariates on nodes ### 2.1 The model Consider a network of $n$ nodes, described by the adjacency matrix $Y = (Y_{ij})_{1 \le i,j \le n}$. Each node $i$ is described by a vector of $p$ covariates $x_i = (x_{i1}, \dots, x_{ip})^\top \in \mathbb{R}^p$. The first covariate is the intercept: $x_{i1} = 1$ for all $i$. Each node $i$ belongs to one block $Z_i \in \{1, \dots, Q\}$. The memberships are independent and follow a multinomial logistic regression on the covariates: $$ \mathbb{P}(Z_i = q \mid x_i) = \pi_q(x_i) = \frac{\exp(x_i^\top \beta_q)}{\sum_{l=1}^{Q} \exp(x_i^\top \beta_l)}, \qquad q = 1, \dots, Q, $$ where $\beta_q \in \mathbb{R}^p$. For identifiability, the last block is the reference: $\beta_Q = 0$. The parameter is gathered in the $p \times Q$ matrix $B = (\beta_1, \dots, \beta_Q)$, whose last column is null. The coefficient $\beta_{kq}$ is the effect of covariate $k$ on the log-odds of belonging to block $q$ rather than to block $Q$: $$ \log \frac{\pi_q(x_i)}{\pi_Q(x_i)} = x_i^\top \beta_q = \sum_{k=1}^p x_{ik} \beta_{kq}. $$ Given the blocks, the edges are independent and their distribution only depends on the blocks of the nodes they involve. In the Bernoulli case: $$ Y_{ij} \mid Z_i = q, Z_j = l \sim \mathcal{B}ern(\alpha_{ql}). $$ Poisson and Gaussian emission distributions are also available, as in the standard SBM. ::: {.callout-note} ## The case with no covariates When the only covariate is the intercept ($p = 1$, $x_i = 1$), $\pi_q(x_i)$ no longer depends on $i$ and we recover the standard SBM with $\pi_q = \exp(\beta_{1q}) / \sum_l \exp(\beta_{1l})$. ::: ### 2.2 Model selection: the ICL {#sec-icl} The parameters $\theta = (B, \alpha)$ are estimated with a variational EM algorithm, for a range of numbers of blocks $Q$. The number of blocks, and more generally the model, is selected with the Integrated Classification Likelihood (ICL). For a model with $Q$ blocks and $p$ covariates on nodes, the ICL computed by the package is $$ \mathrm{ICL}(Q, p) = \mathbb{E}_{Z\sim p^{Var}}[\log p(Y, Z \mid X; \widehat{\theta})] - \frac{1}{2}\left[ Q^2 \log\big(n(n-1)\big) + p\,(Q-1) \log n \right], $$ where: - $\mathbb{E}_{Z\sim p^{Var}}[\log p(Y, Z \mid X; \widehat{\theta})]$ is the integrated complete-data log-likelihood where the latent variables $Z$ are integrated against to variational distribution $p^{Var}$. - $$ \log p(Y, Z \mid X; \theta) = \underbrace{\log p(Y \mid Z; \alpha)}_{\text{network}} + \underbrace{\log p(Z \mid X; B)}_{\text{memberships}}; $$ - $Q^2$ is the number of connectivity parameters for a directed Bernoulli SBM (would be $Q(Q+1)/2$ for an undirected one), penalized by the logarithm of the number of dyads $\log (n(n-1))$ ($\log (n(n-1))/2$ for an undirected one); - $p(Q-1)$ is the number of free parameters in $B$, penalized by the logarithm of the number of nodes $n$. Without covariates, $p = 1$ and we recover the usual ICL of the SBM. Adding covariates increases the penalty, so the ICL favors the model with covariates only if they increase $\mathbb{E}_{Z\sim p^{Var}}[\log p(Y, Z \mid X; \widehat{\theta})]$ enough, mainly through the term $\mathbb{E}_{Z\sim p^{Var}}[\log p(Z \mid X; \widehat{\theta})]$. The number of blocks is chosen within each model, then the best model is the one with the highest ICL. ### 2.3 Simulation of a directed network We simulate a directed binary network with $n = 120$ nodes, $Q = 4$ blocks and $p = 3$ covariates (the intercept and two Gaussian covariates). ```{r simu-param} set.seed(3) n <- 120 # nodes Q <- 4 # blocks p <- 3 # covariates (including the intercept) X <- cbind(1, matrix(rnorm((p - 1) * n), ncol = p - 1)) colnames(X) <- c("intercept", "x1", "x2") head(X) ``` The $p \times Q$ matrix $B$ of the effects of the covariates is defined with the last block as reference (null last column). ```{r simu-B} B <- rbind(c(-0.01, 3, 4, 2), c( 5, 0.4, -2, 0), c(-0.7, 6, 0, -3)) B <- B - B[, Q] # last block as reference B <- B[, order(colSums(abs(B)), decreasing = TRUE)] rownames(B) <- colnames(X) B ``` **For this trial**, the connectivity matrix $\alpha$ is drawn at random. The network is then simulated with `sampleSimpleSBM`, passing the covariates and their effects through the arguments `nodesCovariates` and `nodesCovariatesParam`. ::: {.callout-note} `blockProp` is not given: the block proportions are driven by the covariates. ::: ```{r simu-sbm} alpha <- matrix(rbeta(Q * Q, 1, 8), Q, Q) round(alpha, 2) mySampler <- sampleSimpleSBM( nbNodes = n, connectParam = list(mean = alpha), model = "bernoulli", directed = TRUE, nodesCovariates = X, nodesCovariatesParam = B ) Z <- mySampler$memberships Y <- mySampler$networkData table(Z) ``` ```{r simu-plot} plot(mySampler) ``` To assess the methods in a more difficult setting, we also build a noisy version of the network, in which each entry of $Y$ is flipped with probability $\varepsilon = 0.1$. The noisy network is still an SBM, with connectivity $(1 - \varepsilon) \alpha_{ql} + \varepsilon (1 - \alpha_{ql})$: the blocks are less contrasted, hence harder to recover from the network only. ```{r simu-noisy} epsilon <- 0.1 Noise <- matrix(rbinom(n * n, 1, epsilon), n, n) Ynoisy <- Y * (1 - Noise) + (1 - Y) * Noise ``` ### 2.4 Estimation by VEM The SBM with covariates on nodes is fitted with `estimateSimpleSBM` and the argument `nodesCovariates`. ::: {.callout-note} ## Intercept The matrix of covariates must contain the column of ones if an intercept is wanted: it is **not** added automatically. ::: ```{r fit-covar} #| eval: !expr exists("runFits") && runFits estimOptions <- list(verbosity = 0, plot = FALSE) fitCovar <- estimateSimpleSBM( Y, model = "bernoulli", directed = TRUE, nodesCovariates = X, estimOptions = estimOptions ) ``` ```{r print-fit-covar} fitCovar ``` For comparison, we fit the standard SBM, without covariates. ```{r fit-no-covar} #| eval: !expr exists("runFits") && runFits fitNoCovar <- estimateSimpleSBM( Y, model = "bernoulli", directed = TRUE, estimOptions = estimOptions ) ``` As usual, the number of blocks is selected with the ICL: ```{r nb-blocks} c(noCovar = fitNoCovar$nbBlocks, covar = fitCovar$nbBlocks) ``` and the estimated clusterings can be compared to the true one: ```{r table-clustering} table(estimated = fitNoCovar$memberships, true = Z) table(estimated = fitCovar$memberships, true = Z) ``` ```{r text-clean, include = FALSE} ariNoCovar <- ARI(fitNoCovar$memberships, Z) ariCovar <- ARI(fitCovar$memberships, Z) sameClust <- abs(ariCovar - ariNoCovar) < 0.05 ``` For this particular case, the clusters are `r quality(min(ariNoCovar, ariCovar))` recovered, up to a label switching of the blocks (ARI = `r fmt(ariNoCovar)` without covariates and `r fmt(ariCovar)` with covariates). ### 2.5 Estimated effects of the covariates The estimated matrix $\widehat{B}$ is given by the field `nodesCovariatesParam`, with the last block as reference. ```{r B-hat} round(fitCovar$nodesCovariatesParam, 2) ``` ::: {.callout-note} ## Label switching The blocks being defined up to a permutation, the columns of $\widehat{B}$ are not in the same order as those of the true $B$ (used to simulate the data), and the reference block may differ. To compare them, we match each true block to its estimated counterpart, reorder the columns of $\widehat{B}$ accordingly and take the same reference block as in the simulation. ::: ```{r align-B} matchBlocks <- function(estimated, true) { apply(table(estimated, true), 2, which.max) } perm <- matchBlocks(fitCovar$memberships, Z) Bhat <- fitCovar$nodesCovariatesParam[, perm] Bhat <- Bhat - Bhat[, Q] rownames(Bhat) <- colnames(X) round(Bhat, 2) B ``` We can now compare the resulting probabilities $\widehat{\mathbb{P}}(Z_i=q) = \widehat{\pi}_q(x_i)$ with their true values $\pi_q(x_i)$. ```{r plot-pi} softmax <- function(eta) { eta <- eta - apply(eta, 1, max) exp(eta) / rowSums(exp(eta)) } probs <- data.frame( true = c(softmax(X %*% B)), estimated = c(softmax(X %*% Bhat)), block = factor(rep(1:Q, each = n)) ) ggplot(probs) + aes(x = true, y = estimated, color = block) + geom_point(alpha = 0.6) + geom_abline(intercept = 0, slope = 1) + labs(x = expression(pi[q](x[i])), y = expression(hat(pi)[q](x[i]))) ``` ### 2.6 Are the covariates selected? On this network, the clusterings obtained with and without covariates are `r if (sameClust) "almost identical" else "different"``r if (sameClust && ariNoCovar > 0.9) ": the network alone is enough to recover the blocks" else ""`. We can check whether the ICL favors the model with covariates. To do so, we also fit a model with two covariates drawn independently of everything else, hence unrelated to the blocks. ```{r fit-useless} #| eval: !expr exists("runFits") && runFits set.seed(123) Xuseless <- cbind(1, matrix(rnorm(2 * n), ncol = 2)) fitUseless <- estimateSimpleSBM( Y, model = "bernoulli", directed = TRUE, nodesCovariates = Xuseless, estimOptions = estimOptions ) ``` ```{r res-useless} resClean <- data.frame( covariates = c("none", "x1 + x2", "two useless covariates"), nbBlocks = c(fitNoCovar$nbBlocks, fitCovar$nbBlocks, fitUseless$nbBlocks), ARI = c(ARI(fitNoCovar$memberships, Z), ARI(fitCovar$memberships, Z), ARI(fitUseless$memberships, Z)), ICL = c(fitNoCovar$ICL, fitCovar$ICL, fitUseless$ICL) ) kable(resClean, digits = 2) ``` ```{r text-useless, include = FALSE} dIclCovar <- fitCovar$ICL - fitNoCovar$ICL dIclUseless <- fitUseless$ICL - fitNoCovar$ICL txtSelect <- paste0( if (ariCovar - ariNoCovar < 0.05) "The covariates barely change the clustering. " else "The covariates improve the clustering. ", if (dIclCovar > 0) "The ICL selects" else "However, the ICL does not select", " the model with $x_1$ and $x_2$ (difference of ICL with the model without covariates: ", fmt(dIclCovar, 1), "), ", if (dIclUseless > 0) "and the useless covariates are selected too" else "whereas the useless covariates are not selected", " (difference of ICL: ", fmt(dIclUseless, 1), ")." ) ``` `r txtSelect` To understand why, recall that the ICL ([see above](#sec-icl)) is based on $\log p(Y, Z \mid X)$, which contains the term $\log p(Z \mid X)$: without covariates it is $\sum_i \sum_q \hat{\tau}^{VE}_{iq} \log \widehat{\pi}_{q}$, with covariates it is $\sum_i \sum_q \hat{\tau}^{VE}_{iq} \log \widehat{\pi}_{q}(x_i)$. ```{r logpZ} ElogpZ <- c( none = sum(fitNoCovar$probMemberships%*%log(fitNoCovar$blockProp)), "x1 + x2" = sum(fitCovar$probMemberships*log(softmax(X %*% fitCovar$nodesCovariatesParam))) ) ElogpZ ``` ```{r text-logpZ, include = FALSE} gainZ <- unname(diff(ElogpZ)) penZ <- 0.5 * (p - 1) * (fitCovar$nbBlocks - 1) * log(n) ``` `r if (gainZ > penZ) "The blocks being well predicted by the covariates" else "The blocks being poorly predicted by the covariates"`, this term `r if (gainZ > 0) "increases" else "decreases"` by `r fmt(abs(gainZ), 1)`, `r if (gainZ > 0) compare(gainZ, penZ) else "while the ICL also pays"` the additional penalty $\frac{1}{2}(p-1)(Q-1)\log n =$ `r fmt(penZ, 1)` due to the $(p-1)(Q-1)$ additional parameters. ::: {.callout-important} ## What the ICL selects The ICL selects the covariates that **predict** the block memberships, even when they do not **improve** the clustering`r if (sameClust && dIclCovar > 0) ", as here" else ""`. Conversely, covariates unrelated to the blocks are not selected. Comparing the ICL of models with and without covariates therefore assesses whether the covariates explain the memberships, not whether they change the clustering. ::: ### 2.7 Gain in clustering We now fit both models on the noisy network and compare the ARI of the four estimated clusterings. The aim is to illustrate the fact that, if the covariates drive the blocks, they can help to recover the topology of the network. ```{r fit-noisy} #| eval: !expr exists("runFits") && runFits fitNoCovarNoisy <- estimateSimpleSBM( Ynoisy, model = "bernoulli", directed = TRUE, estimOptions = estimOptions ) fitCovarNoisy <- estimateSimpleSBM( Ynoisy, model = "bernoulli", directed = TRUE, nodesCovariates = X, estimOptions = estimOptions ) ``` ```{r ARI-simple} resSimple <- data.frame( network = rep(c("original", "noisy"), each = 2), model = rep(c("without covariates", "with covariates"), 2), nbBlocks = c(fitNoCovar$nbBlocks, fitCovar$nbBlocks, fitNoCovarNoisy$nbBlocks, fitCovarNoisy$nbBlocks), ARI = c(ARI(fitNoCovar$memberships, Z), ARI(fitCovar$memberships, Z), ARI(fitNoCovarNoisy$memberships, Z), ARI(fitCovarNoisy$memberships, Z)) ) kable(resSimple, digits = 2) ``` ```{r text-noisy, include = FALSE} ariNoCovarNoisy <- ARI(fitNoCovarNoisy$memberships, Z) ariCovarNoisy <- ARI(fitCovarNoisy$memberships, Z) txtNoisy <- paste0( if (sameClust && min(ariNoCovar, ariCovar) > 0.9) "On the original network, the structure is strong enough to be recovered from the network alone, and both models give similar clusterings. " else sprintf("On the original network, the ARI is %s without covariates and %s with covariates. ", fmt(ariNoCovar), fmt(ariCovar)), sprintf("On the noisy network, the standard SBM selects %s%d blocks (ARI = %s)", if (fitNoCovarNoisy$nbBlocks < Q) "only " else "", fitNoCovarNoisy$nbBlocks, fmt(ariNoCovarNoisy)), if (ariNoCovarNoisy < 0.8) ": the network alone does not carry enough information. " else ". ", if (ariCovarNoisy > ariNoCovarNoisy + 0.05) sprintf("The covariates bring the missing information: the SBM with covariates selects %d blocks (ARI = %s).", fitCovarNoisy$nbBlocks, fmt(ariCovarNoisy)) else sprintf("The SBM with covariates selects %d blocks (ARI = %s): here the covariates do not improve the clustering.", fitCovarNoisy$nbBlocks, fmt(ariCovarNoisy)) ) ``` `r txtNoisy` ### 2.8 Testing the effect of covariates with the ICL We now illustrate the fact that the ICL defined [above](#sec-icl) can also be used to select the covariates that are involved in the block assignation. We compare four models on the noisy network: no covariate (intercept only), $x_1$ only, $x_2$ only, and both. We also add a covariate $x_3$, drawn independently of everything else, which has no effect on the clustering. ```{r test-covar} #| eval: !expr exists("runFits") && runFits x3 <- rnorm(n) covarSets <- list( "none" = NULL, "x1" = X[, c("intercept", "x1")], "x2" = X[, c("intercept", "x2")], "x1 + x2" = X, "x1 + x2 + x3" = cbind(X, x3 = x3) ) fits <- lapply(covarSets, function(covar) { if (is.null(covar)) { estimateSimpleSBM(Ynoisy, model = "bernoulli", directed = TRUE, estimOptions = estimOptions) } else { estimateSimpleSBM(Ynoisy, model = "bernoulli", directed = TRUE, nodesCovariates = covar, estimOptions = estimOptions) } }) ``` ```{r res-test-covar} resICL <- data.frame( covariates = names(fits), nbBlocks = sapply(fits, function(f) f$nbBlocks), ICL = sapply(fits, function(f) f$ICL), ARI = sapply(fits, function(f) ARI(f$memberships, Z)) ) resICL$deltaICL <- resICL$ICL - resICL$ICL[1] kable(resICL, digits = 2, row.names = FALSE) ``` ```{r text-test-covar, include = FALSE} covarLabels <- c("none" = "no covariate", "x1" = "$x_1$ only", "x2" = "$x_2$ only", "x1 + x2" = "$x_1$ and $x_2$", "x1 + x2 + x3" = "$x_1$, $x_2$ and $x_3$") dICL <- setNames(resICL$deltaICL, resICL$covariates) best <- resICL$covariates[which.max(resICL$ICL)] alone <- dICL[c("x1", "x2")] > 0 txtTest <- paste0( if (all(alone)) "Here, each of $x_1$ and $x_2$ has an effect on its own" else if (any(alone)) sprintf("Here, only $x_%d$ has an effect on its own", which(alone)) else "Here, neither $x_1$ nor $x_2$ improves the ICL on its own", if (best == "x1 + x2") ", and the best model (highest ICL) is the one with both of them, which is the true model. " else sprintf(", and the best model (highest ICL) is the one with %s, whereas the true model involves $x_1$ and $x_2$. ", covarLabels[best]), if (dICL["x1 + x2 + x3"] < dICL["x1 + x2"]) "Adding the irrelevant covariate $x_3$ decreases the ICL: its small gain in fit does not compensate for the $Q - 1$ additional parameters." else "Adding the irrelevant covariate $x_3$ increases the ICL here: by chance, it slightly helps to predict the memberships of this sample." ) ``` `deltaICL` is the difference of ICL with the model without covariates: a positive value means that the covariates are useful to explain the clustering. `r txtTest` ::: {.callout-note} The ICL is not a statistical test in the strict sense (it does not provide a $p$-value), but a model selection criterion that compares models of different complexities fitted on the same data. ::: ## 3. Bipartite SBM with covariates on nodes The same model and inference can be considered on bipartite models when covariates are provided, describing one or the two types of nodes. ### 3.1 The model A bipartite network links $n_1$ nodes in rows to $n_2$ nodes in columns, through an incidence matrix $Y = (Y_{ij})_{1 \le i \le n_1, 1 \le j \le n_2}$. Each row node $i$ (resp. column node $j$) belongs to a block $Z^1_i \in \{1, \dots, Q_1\}$ (resp. $Z^2_j \in \{1, \dots, Q_2\}$). Rows and columns can have their own covariates, $x_i \in \mathbb{R}^{p_1}$ and $w_j \in \mathbb{R}^{p_2}$, acting through two multinomial logistic regressions: $$ \mathbb{P}(Z^1_i = q \mid x_i) = \frac{\exp(x_i^\top \beta_q)}{\sum_{l=1}^{Q_1} \exp(x_i^\top \beta_l)}, \qquad \mathbb{P}(Z^2_j = r \mid w_j) = \frac{\exp(w_j^\top \gamma_r)}{\sum_{s=1}^{Q_2} \exp(w_j^\top \gamma_s)}, $$ with $\beta_{Q_1} = 0$ and $\gamma_{Q_2} = 0$. All the memberships are independent and, given the blocks, the entries of $Y$ are independent with $$ Y_{ij} \mid Z^1_i = q, Z^2_j = r \sim \mathcal{B}(\alpha_{qr}). $$ ### 3.2 Simulation We simulate a bipartite network with $n_1 = 120$ rows in $Q_1 = 4$ blocks (with the same covariates design as before) and $n_2 = 100$ columns in $Q_2 = 3$ blocks, described by an intercept and one Gaussian covariate. In the bipartite case, the covariates and their effects are given as lists of two matrices (rows, then columns). ```{r simu-bipartite} set.seed(4) n1 <- 120; Q1 <- 4 n2 <- 100; Q2 <- 3 X1 <- cbind(1, matrix(rnorm(2 * n1), ncol = 2)) # row covariates X2 <- cbind(1, rnorm(n2)) # column covariates B1 <- rbind(c(-0.01, 3, 4, 2), c( 5, 0.4, -2, 0), c(-0.7, 6, 0, -3)) B1 <- B1 - B1[, Q1] B1 <- B1[, order(colSums(abs(B1)), decreasing = TRUE)] B2 <- rbind(c(0, 1, 1), c(2, 0, 0.4)) B2 <- B2 - B2[, Q2] B2 <- B2[, order(colSums(abs(B2)), decreasing = TRUE)] alphaBip <- matrix(0, Q1 , Q2) alphaBip[,1] = c(0.2,0.4,0.5,0.05) alphaBip[,2] = c(0.5,0.2,0.05,0.05) alphaBip[,3] = c(0.1,0.2,0.05,0.3) myBipSampler <- sampleBipartiteSBM( nbNodes = c(n1, n2), connectParam = list(mean = alphaBip), model = "bernoulli", dimLabels = c("row", "col"), nodesCovariates = list(X1, X2), nodesCovariatesParam = list(B1, B2) ) Zbip <- myBipSampler$memberships Ybip <- myBipSampler$networkData lapply(Zbip, table) ``` ```{r simu-bipartite-plot} plot(myBipSampler) ``` ### 3.3 Missing data On the bipartite network, instead of flipping the entries, we degrade the network by removing some of them: each entry of $Y$ is set to `NA` independently with probability $\varepsilon$. We consider four levels of missing data: $\varepsilon = 10\%$, $30\%$, $60\%$ and $80\%$. ::: {.callout-note} ## Missing data in SBM Missing entries are handled natively by the inference: the likelihood $\log p(Y \mid Z; \alpha)$ only involves the observed entries. Since here the entries are missing at random, independently of the blocks and of the network, the observed entries still follow the same SBM: the missing data do not bias the estimation, but they reduce the information available to recover the blocks. ::: ```{r simu-bipartite-NA} epsilonNA <- c(0.1, 0.3, 0.6,0.8) YbipNA <- lapply(epsilonNA, function(eps) { Yna <- Ybip Yna[matrix(rbinom(n1 * n2, 1, eps), n1, n2) == 1] <- NA Yna }) names(YbipNA) <- paste0(100 * epsilonNA, "% NA") sapply(YbipNA, function(Yna) mean(is.na(Yna))) ``` We plot the partially observed matrix where the non observed edges are plotted in white. ```{r simu-plot-bipartite-noise} plotMyMatrix(YbipNA[[3]],clustering=Zbip) ``` ### 3.4 Estimation and gain in clustering The bipartite SBM with covariates on nodes is fitted with `estimateBipartiteSBM`, giving the list of row and column covariates in `nodesCovariates`. We fit the models with and without covariates on the original network and on each of the networks with missing data. ```{r fit-bipartite} #| eval: !expr exists("runFits") && runFits networksBip <- c(list(original = Ybip), YbipNA) fitBip <- lapply(networksBip, function(Yobs) { list( noCovar = estimateBipartiteSBM( Yobs, model = "bernoulli", dimLabels = c("row", "col"), estimOptions = estimOptions), covar = estimateBipartiteSBM( Yobs, model = "bernoulli", dimLabels = c("row", "col"), nodesCovariates = list(X1, X2), estimOptions = estimOptions) ) }) ``` ```{r print-fit-bipartite} fitBip$original$covar ``` The estimated effects of the covariates are given, for rows and columns, by `nodesCovariatesParam`: ```{r bipartite-B-hat} lapply(fitBip$original$covar$nodesCovariatesParam, round, 2) ``` ```{r ARI-bipartite} resBip <- do.call(rbind, lapply(names(fitBip), function(net) { data.frame( network = net, model = c("without covariates", "with covariates"), nbRowBlocks = sapply(fitBip[[net]], function(f) f$nbBlocks[1]), nbColBlocks = sapply(fitBip[[net]], function(f) f$nbBlocks[2]), ARIrow = sapply(fitBip[[net]], function(f) ARI(f$memberships[[1]], Zbip[[1]])), ARIcol = sapply(fitBip[[net]], function(f) ARI(f$memberships[[2]], Zbip[[2]])), deltaICL = sapply(fitBip[[net]], function(f) f$ICL - fitBip[[net]]$noCovar$ICL) ) })) kable(resBip, digits = 2, row.names = FALSE) ``` `deltaICL` is the difference of ICL with the model without covariates fitted on the same network. ::: {.callout-note} The ICL can only be compared between models fitted on the same network: with more missing entries, the likelihood involves fewer terms, so the ICL is not comparable across levels of missing data. ::: ```{r ARI-bipartite-summary, include = FALSE} lab <- names(YbipNA) ## comments of the results, generated from them nbB <- function(net, mod) fitBip[[net]][[mod]]$nbBlocks ariB <- function(net, mod, side) ARI(fitBip[[net]][[mod]]$memberships[[side]], Zbip[[side]]) blocksTxt <- function(nb) sprintf("%d $\\times$ %d block%s", nb[1], nb[2], if (prod(nb) > 1) "s" else "") pct <- function(i) paste0(100 * epsilonNA[i], "%") # original network ariOrig <- c(ariB("original", "noCovar", 1), ariB("original", "noCovar", 2), ariB("original", "covar", 1), ariB("original", "covar", 2)) trueNb <- function(net) all(nbB(net, "noCovar") == c(Q1, Q2)) && all(nbB(net, "covar") == c(Q1, Q2)) improved <- any(ariOrig[3:4] - ariOrig[1:2] > 0.05) dIclOrig <- fitBip$original$covar$ICL - fitBip$original$noCovar$ICL txtBipOrig <- paste0( if (trueNb("original") && min(ariOrig) > 0.95) "On the original network, the structure is strong enough to be recovered from the network alone: both models find the $Q_1 = 4$ row blocks and the $Q_2 = 3$ column blocks with almost perfect clusterings. " else sprintf("On the original network, the model without covariates finds %s (ARI = %s and %s for rows and columns), and the model with covariates %s (ARI = %s and %s). ", blocksTxt(nbB("original", "noCovar")), fmt(ariOrig[1]), fmt(ariOrig[2]), blocksTxt(nbB("original", "covar")), fmt(ariOrig[3]), fmt(ariOrig[4])), if (!improved && dIclOrig > 0) "The covariates do not improve the clustering, but the ICL still selects them, since they predict the memberships." else if (!improved) "The covariates do not improve the clustering, and the ICL does not select them." else if (dIclOrig > 0) "The covariates improve the clustering, and the ICL selects them." else "The covariates improve the clustering, but the ICL does not select them." ) # moderate levels of missing data ariNoCovRow <- sapply(lab, function(l) ariB(l, "noCovar", 1)) ariCovRow <- sapply(lab, function(l) ariB(l, "covar", 1)) txtBipModerate <- if (all(ariNoCovRow[1:2] > 0.9) && min(ariOrig[1:2]) > 0.9) sprintf("The SBM without covariates is robust to moderate levels of missing data: with %s and %s of missing entries, the clusterings are nearly unchanged (row ARI = %s and %s).", pct(1), pct(2), fmt(ariNoCovRow[1]), fmt(ariNoCovRow[2])) else sprintf("With %s and %s of missing entries, the SBM without covariates gives row ARI = %s and %s, and the SBM with covariates %s and %s.", pct(1), pct(2), fmt(ariNoCovRow[1]), fmt(ariNoCovRow[2]), fmt(ariCovRow[1]), fmt(ariCovRow[2])) # high level of missing data txtBip3 <- paste0( sprintf("With %s of missing entries, ", pct(3)), if (ariNoCovRow[3] < 0.9) "the network alone is no longer informative enough: " else "", sprintf("the standard SBM selects %d row blocks (ARI = %s)", nbB(lab[3], "noCovar")[1], fmt(ariNoCovRow[3])), if (ariCovRow[3] > ariNoCovRow[3] + 0.05) sprintf(", whereas the SBM with covariates finds %d row blocks (ARI = %s).", nbB(lab[3], "covar")[1], fmt(ariCovRow[3])) else sprintf(", and the SBM with covariates %d row blocks (ARI = %s).", nbB(lab[3], "covar")[1], fmt(ariCovRow[3])) ) # very high level of missing data nb4 <- nbB(lab[4], "noCovar") txtBip4 <- paste0( sprintf("With %s of missing entries, ", pct(4)), if (all(nb4 == 1)) "the network alone does not reveal any structure (1 $\\times$ 1 block)" else sprintf("the standard SBM finds %s (ARI = %s and %s)", blocksTxt(nb4), fmt(ariNoCovRow[4]), fmt(ariB(lab[4], "noCovar", 2))), sprintf(", while the SBM with covariates finds %s (ARI = %s and %s for rows and columns).", blocksTxt(nbB(lab[4], "covar")), fmt(ariCovRow[4]), fmt(ariB(lab[4], "covar", 2))) ) # gain brought by the covariates and ICL gainRow <- c(ariOrig[3] - ariOrig[1], ariCovRow - ariNoCovRow) dIclBip <- sapply(names(fitBip), function(net) fitBip[[net]]$covar$ICL - fitBip[[net]]$noCovar$ICL) txtBipEnd <- paste0( if (all(diff(gainRow) > -0.02) && gainRow[length(gainRow)] > gainRow[1] + 0.05) "The more entries are missing, the more the information brought by the covariates matters. " else "", if (all(dIclBip > 0)) "For all the networks, the ICL selects the model with covariates." else if (all(dIclBip <= 0)) "For none of the networks does the ICL select the model with covariates." else sprintf("The ICL selects the model with covariates only for the following networks: %s.", paste(names(fitBip)[dIclBip > 0], collapse = ", ")) ) ``` `r txtBipOrig` `r txtBipModerate` `r txtBip3` `r txtBip4` `r txtBipEnd` ### 3.5 Covariates on one type of nodes only Covariates can be given for only one type of nodes, by setting an empty matrix for the other one. The column blocks then follow a standard multinomial distribution with proportions $\pi^2 = (\pi^2_1, \dots, \pi^2_{Q_2})$. We illustrate it on the network with `r 100 * epsilonNA[3]`% of missing entries`r if (ariCovRow[3] > ariNoCovRow[3] + 0.05) ", for which the covariates improve the clustering" else ""`. ```{r fit-bipartite-row-only} #| eval: !expr exists("runFits") && runFits fitBipRowOnly <- estimateBipartiteSBM( YbipNA[[lab[3]]], model = "bernoulli", dimLabels = c("row", "col"), nodesCovariates = list(X1, matrix(0, 0, 0)), estimOptions = estimOptions ) ``` ```{r nb-blocks-row-only} fitBipRowOnly$nbBlocks ``` The ICL can then be used, as in the simple case, to decide which covariates are useful: ```{r ICL-bipartite} iclBip <- c(none = fitBip[[lab[3]]]$noCovar$ICL, rowsOnly = fitBipRowOnly$ICL, rowsAndCols = fitBip[[lab[3]]]$covar$ICL) iclBip ``` Here the highest ICL is obtained `r c(none = "without covariates", rowsOnly = "with covariates on the rows only", rowsAndCols = "with covariates on both rows and columns")[names(which.max(iclBip))]`. ## References