Repository navigation
Expand file tree
/
Copy pathsource_code.R
More file actions
1354 lines (1073 loc) · 55.8 KB
/
Copy pathsource_code.R
File metadata and controls
1354 lines (1073 loc) · 55.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Load required libraries (used versions mentioned)
library(tidyverse) #2.0.0
library(readxl) #1.4.3
library(Hmisc) #4.7-1
library(ggplot2) #3.5.1
library(data.table) #1.14.2
library(reshape2) #1.4.4
library(dplyr) #1.1.4
library(influential) #2.2.9
library(emln) #0.0.1
library(infomapecology)#2.0.1
library(magrittr) #2.0.3
library(combinat) #0.0-8
library(khroma) #1.12.0
library(grid) #4.2.1
library(gridExtra) #2.3
library(ggpubr) #0.6.0
library(igraph) #1.5.1
# ----------------------------------------
# Step 1: Data Import and Pre-processing
# ----------------------------------------
# Define the experimental groups and batch names
groups <- c("WT", "unevo", "evolved", "evoWT", "mut")
batches <- c("585_1", "585_2")
sheet_names <- batches
data <- "Data/LC-MS_results.xlsx"
# Function to preprocess each sheet
process_data <- function(sheet_name, data_file = data) {
# Read sheet, with checks that file and sheets exist
if (!file.exists(data_file)) stop(sprintf("File not found: %s", data_file))
sheets <- tryCatch(readxl::excel_sheets(data_file), error = function(e) NULL)
if (is.null(sheets) || !(sheet_name %in% sheets)) {
stop(sprintf("Sheet '%s' not found in %s. Available: %s",
sheet_name, data_file, paste(sheets %||% character(0), collapse = ", ")))
}
df <- read_excel(data_file, sheet = sheet_name) # Load Excel sheet into DataFrame
# Check that "cluster" column is not missing
if (!("cluster" %in% colnames(df))) stop(sprintf("Missing column 'cluster' in sheet '%s'", sheet_name))
# Build regex pattern for selecting group or 'cluster' (metabolic feature) columns
pattern <- paste0("^(", paste(groups, collapse = "|"), "|cluster)")
sample_cols <- grep(pattern, colnames(df), ignore.case = TRUE)
# Check that groups match the pattern
if (length(sample_cols) == 0) stop(sprintf("No group/cluster columns matched pattern '%s'", pattern))
# Replace zeros with NA in sample columns
df[sample_cols][df[sample_cols] == 0] <- NA
# Loop over each group and extract corresponding matrix
lapply(groups, function(grp, df) {
# Select all columns starting with the group name
col_range <- df %>% select(starts_with(grp))
# Check that columns exist for each group
if (ncol(col_range) == 0) {
message(sprintf("[WARN] No columns found for group '%s' in sheet '%s' ??? returning empty matrix.", grp, sheet_name))
return(matrix(numeric(0), nrow = 0, ncol = 0))
}
# Add 'cluster' column before the group columns
tbl <- col_range %>% add_column(cluster = df$cluster, .before = TRUE)
# Set rownames to be the cluster identifiers
rownames(tbl) <- tbl$cluster
# Convert to matrix and apply log-transformation after transposition
mat <- tbl %>%
data.matrix() %>%
t() %>%
log()
return(mat[-1,]) # Remove the 'cluster' row (which was transposed into a column)
}, df) %>%
setNames(groups) # Assign group names to resulting list
}
# Apply the data processing to each batch (Excel sheet)
batch_data <- lapply(sheet_names, process_data)
names(batch_data) <- batches # Name each processed batch by its original name
# ----------------------------------------
# Step 2: Correlation Analysis
# ----------------------------------------
# Step 2.1 - Define functions
# Function to compute Pearson's correlation values
compute_correlation <- function(data) {
m <- as.matrix(data)
# Make sure data is numeric
if (!all(apply(m, 2, is.numeric))) stop("All columns must be numeric for correlation.")
tryCatch(Hmisc::rcorr(m, type = "pearson"),
error = function(e) stop(sprintf("Correlation failed: %s", e$message)))
}
# Function to convert upper triangle of correlation, p-value, and sample size matrices into a long-format dataframe
flatten_corr_matrix <- function(cormat, pmat, nmat) {
# Checks: make sure the datasets are square numeric matrices with the same dimensions
if (!is.matrix(cormat) || !is.matrix(pmat) || !is.matrix(nmat))
stop("flatten_corr_matrix: cormat, pmat, and nmat must be matrices.")
if (!is.numeric(cormat) || !is.numeric(pmat) || !is.numeric(nmat))
stop("flatten_corr_matrix: cormat, pmat, and nmat must be numeric.")
if (nrow(cormat) != ncol(cormat) || nrow(pmat) != ncol(pmat) || nrow(nmat) != ncol(nmat))
stop("flatten_corr_matrix: cormat, pmat, and nmat must be square matrices.")
if (!all(dim(cormat) == dim(pmat)) || !all(dim(cormat) == dim(nmat)))
stop("flatten_corr_matrix: cormat, pmat, and nmat must have the same dimensions.")
upper_indices <- upper.tri(cormat)
df <- data.frame(
Feature1 = rownames(cormat)[row(cormat)[upper_indices]],
Feature2 = rownames(cormat)[col(cormat)[upper_indices]],
Correlation = cormat[upper_indices],
P_value = pmat[upper_indices],
Sample_Size = nmat[upper_indices]
)
# Adjust p-values for multiple testing using FDR
df$P_value_adj <- p.adjust(df$P_value, method = "fdr", n = length(df$P_value))
return(df)
}
# Function to filter correlations by sign (positive or negative)
filter_correlations <- function(cor_results, experiment_name) {
# Make sure the needed columns are not missing
if (is.null(cor_results$r) || is.null(cor_results$P) || is.null(cor_results$n))
stop("filter_correlations: cor_results must contain r, P, and n.")
df <- flatten_corr_matrix(cor_results$r, cor_results$P, cor_results$n)
df <- df %>% mutate(Experiment = experiment_name)
# If no correlations were found
if (nrow(df) == 0) {
warning(sprintf("filter_correlations: no correlations found for '%s'; returning empty results.", experiment_name))
return(list(all = df, positive = df, negative = df))
}
list(
all = df,
positive = df %>% filter(Correlation > 0),
negative = df %>% filter(Correlation < 0)
)
}
# Function to identify overlapping edges between standard correlation and PCLRC-filtered correlation edges
filter_pclrc <- function(cor_df, pclrc_df) {
# Make sure the needed columns are not missing
if (!all(c("Feature1","Feature2","Correlation") %in% colnames(cor_df)))
stop("filter_pclrc: 'cor_df' must have columns: Feature1, Feature2, Correlation.")
if (!all(c("Feature1","Feature2") %in% colnames(pclrc_df)))
stop("filter_pclrc: 'pclrc_df' must have columns: Feature1, Feature2.")
# Direct matches
common_direct <- bind_rows(cor_df, pclrc_df) %>%
group_by(Feature1, Feature2) %>%
filter(n() > 1) %>%
summarise(Correlation = first(Correlation), .groups = "drop") %>%
drop_na()
# Reverse matches
pclrc_df_reversed <- pclrc_df %>% rename(Feature1 = Feature2, Feature2 = Feature1)
common_reverse <- bind_rows(cor_df, pclrc_df_reversed) %>%
group_by(Feature1, Feature2) %>%
filter(n() > 1) %>%
summarise(Correlation = first(Correlation), .groups = "drop") %>%
drop_na()
# Combine both direct and reverse matches
out <-bind_rows(common_direct, common_reverse)
# Check if no overlapping was found
if (nrow(out) == 0) {
warning("filter_pclrc: no overlapping edges found, returning empty result.")
}
out
}
# Step 2.2 - Correlation analysis for each batch using PCLRC-filtered edges
# NOTE: csv files of the PCLRC-filtered edges were generated with the R script from Di Cesare et al. (2022), GeroScience, 44(2), 1109???1128.
# This script retains only the edges that remain after applying the PCLRC filtering method.
# The function processes all datasets and compares them to precomputed PCLRC edge lists
perform_batch_correlation_analysis <- function(datasets, pclrc_edge_paths) {
# Compute Pearson correlation matrices for each group dataset
correlation_results <- lapply(datasets, compute_correlation)
# Filter correlations by sign
filtered_results <- mapply(filter_correlations, correlation_results, names(correlation_results), SIMPLIFY = FALSE)
# Read PCLRC-filtered edges from CSVs for each group
pclrc_edges <- lapply(pclrc_edge_paths, function(path) {
readr::read_csv(path, show_col_types = FALSE) %>%
select(2:3) %>%
rename(Feature1 = 1, Feature2 = 2)
})
# Ensure overlap uses matching names
if (!all(names(filtered_results) %in% names(pclrc_edges))) {
warning("perform_batch_correlation_analysis: some groups in 'filtered_results' are missing in 'pclrc_edge_paths'. Using available groups only.")
}
# Match positive correlations with PCLRC edges
positive_overlap <- mapply(filter_pclrc,
lapply(filtered_results, `[[`, "positive"),
pclrc_edges,
SIMPLIFY = FALSE)
# Match negative correlations with PCLRC edges
negative_overlap <- mapply(filter_pclrc,
lapply(filtered_results, `[[`, "negative"),
pclrc_edges,
SIMPLIFY = FALSE)
list(
positive_overlap = positive_overlap,
negative_overlap = negative_overlap,
all_filtered_results = filtered_results
)
}
# Batch 1 analysis
pclrc_files_batch1 <- list(
WT = "Data/PCLRC_filtered_edges/WT_PCLRC_edges_1.csv",
unevo = "Data/PCLRC_filtered_edges/unevo_PCLRC_edges_1.csv",
evo = "Data/PCLRC_filtered_edges/evo_PCLRC_edges_1.csv",
evoWT = "Data/PCLRC_filtered_edges/evoWT_PCLRC_edges_1.csv",
mut = "Data/PCLRC_filtered_edges/mut_PCLRC_edges_1.csv"
)
batch1_results <- perform_batch_correlation_analysis(batch_data$`585_1`, pclrc_files_batch1)
# Batch 2 analysis
pclrc_files_batch2 <- list(
WT = "Data/PCLRC_filtered_edges/WT_PCLRC_edges_2.csv",
unevo = "Data/PCLRC_filtered_edges/unevo_PCLRC_edges_2.csv",
evo = "Data/PCLRC_filtered_edges/evo_PCLRC_edges_2.csv",
evoWT = "Data/PCLRC_filtered_edges/evoWT_PCLRC_edges_2.csv",
mut = "Data/PCLRC_filtered_edges/mut_PCLRC_edges_2.csv"
)
batch2_results <- perform_batch_correlation_analysis(batch_data$`585_2`, pclrc_files_batch2)
# ----------------------------------------
# Step 3: Extract Common Correlations Between Batches
# ----------------------------------------
# Function to find shared correlations between two data frames,
# keeping only those with the same sign and averaging their values
common_corrs <- function(df1, df2) {
# Make sure the needes columns exist
req <- c("Feature1","Feature2","Correlation")
if (!all(req %in% colnames(df1))) stop("common_corrs: 'df1' must have columns: Feature1, Feature2, Correlation.")
if (!all(req %in% colnames(df2))) stop("common_corrs: 'df2' must have columns: Feature1, Feature2, Correlation.")
# Make sure the correlation column is numeric
if (!is.numeric(df1$Correlation) || !is.numeric(df2$Correlation)) stop("common_corrs: 'Correlation' must be numeric in both data frames.")
df1 <- df1 %>% mutate(sign = sign(Correlation))
df2 <- df2 %>% mutate(sign = sign(Correlation))
# Find direct matches (same Feature1 and Feature2)
common_1 <- bind_rows(df1, df2) %>%
group_by(Feature1, Feature2,sign) %>%
filter(n() > 1) %>%
summarise(Correlation = mean(Correlation), .groups = "drop")
# Find reverse matches (Feature1 <-> Feature2)
df2_flipped <- setNames(df2, c("Feature2", "Feature1", "Correlation"))
common_2 <- bind_rows(df1, df2_flipped) %>%
group_by(Feature1, Feature2,sign) %>%
filter(n() > 1) %>%
summarise(Correlation = mean(Correlation), .groups = "drop")
# Combine direct and reverse matches
out <- bind_rows(common_1, common_2)
# Check if there are no common correlations
if (nrow(out) == 0) {
warning("common_corrs: no shared correlations found; returning empty result.")
}
out
}
# Wrapper function to apply "common_corrs" function to all groups and both positive/negative networks
extract_common_corrs_all_groups <- function(d1_pos, d1_neg, d2_pos, d2_neg) {
groups <- names(d1_pos)
map(groups, function(g) {
list(
positive = common_corrs(d1_pos[[g]], d2_pos[[g]]), # Common positive correlations
negative = common_corrs(d1_neg[[g]], d2_neg[[g]]) # Common negative correlations
)
}) %>% set_names(groups) # Return named list by group
}
# Apply to both batches and store all common correlations per group
common_corrs_all <- extract_common_corrs_all_groups(
batch1_results$positive_overlap, batch1_results$negative_overlap,
batch2_results$positive_overlap, batch2_results$negative_overlap
)
# ----------------------------------------
# Step 4: Network Comparison and Similarity Analysis (Figure 1E)
# ----------------------------------------
# Function to compute Jaccard similarity index between all bacterial networks
jaccard_between_networks <- function(list_df) {
n <- length(list_df)
res <- matrix(NA, nrow = n, ncol = n)
rownames(res) <- colnames(res) <- names(list_df)
for (i in seq_len(n)) {
for (j in seq_len(n)) {
if (i != j) {
# Get common edges between networks i and j
common <- common_corrs(list_df[[i]], list_df[[j]])
# Calculate Jaccard index: |A ??? B| / |A ??? B|
jacc <- nrow(common) / (nrow(list_df[[i]]) + nrow(list_df[[j]]) - nrow(common))
res[i, j] <- round(jacc, 3)
} else {
res[i, j] <- 1 # similarity of a network with itself
}
}
}
as.data.frame(res)
}
# Function to combine positive and negative edges for each group
combine_pos_neg <- function(common_list) {
map(common_list, ~ bind_rows(.x$positive, .x$negative))
}
# Combine edges for each group
common_combined <- combine_pos_neg(common_corrs_all)
# Compute pairwise Jaccard similarity between all groups
Jaccard_networks <- jaccard_between_networks(common_combined)
#Calculate Jaccard similairty index between networks of the same bacterial group in the two batches
# Combine positive and negative edges per group for each batch
combine_pos_neg_per_batch <- function(pos_list, neg_list) {
mapply(function(p, n) rbind(p, n), pos_list, neg_list, SIMPLIFY = FALSE)
}
batch1_combined <- combine_pos_neg_per_batch(batch1_results$positive_overlap, batch1_results$negative_overlap)
batch2_combined <- combine_pos_neg_per_batch(batch2_results$positive_overlap, batch2_results$negative_overlap)
# Match names to be sure
names(batch1_combined) <- names(batch2_combined) <- names(batch1_results$positive_overlap)
# Now compare the same group between batch1 and batch2
# Create a list of both versions for each group
combined_by_group <- mapply(function(b1, b2) list(batch1 = b1, batch2 = b2),
batch1_combined, batch2_combined,
SIMPLIFY = FALSE)
# Now apply Jaccard on each group separately
jaccard_results_betwenn_batches <- lapply(combined_by_group, jaccard_between_networks)
# ----------------------------------------
# Step 5: Network Construction and Summary Statistics (Table 2)
# ----------------------------------------
# Function to create an igraph network object from a filtered correlation data frame, using the common correlations as weights
create_igraph <- function(df) {
# Ensure the datasets are data frames, that the required columns exist and that the correlation weights are numeric
if (is.null(df) || !is.data.frame(df)) stop("create_igraph: 'df' must be a data.frame.")
req <- c("Feature1", "Feature2", "Correlation")
if (!all(req %in% colnames(df))) {
stop(sprintf("create_igraph: missing required columns: %s",
paste(setdiff(req, colnames(df)), collapse = ", ")))
}
if (nrow(df) == 0) return(igraph::make_empty_graph())
if (!is.numeric(df$Correlation)) stop("create_igraph: 'Correlation' must be numeric.")
if (any(is.na(df$Feature1) | is.na(df$Feature2))) stop("create_igraph: Feature1/Feature2 contain NA.")
edges <- df %>%
dplyr::select(from = Feature1, to = Feature2, weight = Correlation)
igraph::graph_from_data_frame(edges, directed = FALSE)
}
# Create igraph objects for all groups
igraphs_all <- lapply(common_corrs_all, function(group_list) {
lapply(group_list, create_igraph)
})
# Create igraph objects for Batch 1, separately for positive and negative correlation edges
igraphs_batch_1_pos <- lapply(batch1_results$positive_overlap, create_igraph)
igraphs_batch_1_neg <- lapply(batch1_results$negative_overlap, create_igraph)
# Create igraph objects for Batch 2, separately for positive and negative correlation edges
igraphs_batch_2_pos <- lapply(batch2_results$positive_overlap, create_igraph)
igraphs_batch_2_neg <- lapply(batch2_results$negative_overlap, create_igraph)
# Step 5.1: Add singleton nodes to recover full node sets (all metabolic features)
# Function to add singleton vertices (nodes without edges) to the common network,
# to match the union of nodes present in the two batch-specific networks
add_singltons <- function(igraph_common,igraph1,igraph2){
# Make sure the variables are igraph objects
if (is.null(igraph_common) || !inherits(igraph_common, "igraph")) stop("add_singltons: 'igraph_common' must be an igraph object.")
if (is.null(igraph1) || !inherits(igraph1, "igraph")) stop("add_singltons: 'igraph1' must be an igraph object.")
if (is.null(igraph2) || !inherits(igraph2, "igraph")) stop("add_singltons: 'igraph2' must be an igraph object.")
# Fetch vertex names (may be NULL for unnamed graphs)
n1 <- names(igraph::V(igraph1)); if (is.null(n1)) n1 <- character(0)
n2 <- names(igraph::V(igraph2)); if (is.null(n2)) n2 <- character(0)
nc <- names(igraph::V(igraph_common)); if (is.null(nc)) nc <- character(0)
# Find intersection of node names present in both batch networks
re <- intersect(n1, n2)
# Determine nodes present in intersection but missing from the common network
rest <- setdiff(re, nc)
# Add missing nodes as vertices to the common network, preserving their names
common_with_single<- add.vertices(igraph_common, length(rest), attr = list(name = rest))
return(common_with_single)
}
# Wrapper function to apply "add_singltons" function across all groups for a (for positive or negative data)
add_singltons_all_nested <- function(igraphs_all, batch1_list, batch2_list, polarity) {
lapply(names(igraphs_all), function(group_name) {
ig_common <- igraphs_all[[group_name]][[polarity]]
# Use 'unevo' group networks from both batches as reference for node sets, as it contains all nodes including singletons
ig1 <- batch1_list[["unevo"]]
ig2 <- batch2_list[["unevo"]]
# Add missing singleton nodes to the common network
result <- add_singltons(ig_common, ig1, ig2)
return(result)
}) %>%
setNames(names(igraphs_all))
}
# Add singleton nodes for all groups in positive networks
igraphs_all_pos_s <- add_singltons_all_nested(
igraphs_all,
igraphs_batch_1_pos,
igraphs_batch_2_pos,
polarity = "positive"
)
# Add singleton nodes for all groups in negative networks
igraphs_all_neg_s <- add_singltons_all_nested(
igraphs_all,
igraphs_batch_1_neg,
igraphs_batch_2_neg,
polarity = "negative"
)
# Step 5.2: Compute global network summary properties:
#number of nodes, number of edges, density, average degree, average correlation weight, network diameter,
#global clustering coefficient, average shortest path and average neighborhood connectivity.
# Initialize an empty data frame to hold summary statistics for positive and negative networks with singleton nodes
amount_of_v_and_e_with_singltons <- data.frame(
Group = character(),
V_pos = integer(),
E_pos = integer(),
dens_pos = numeric(),
V_neg = integer(),
E_neg = integer(),
dens_neg = numeric(),
stringsAsFactors = FALSE
)
# Loop through each group and calculate number of vertices, edges, and edge density for positive and negative networks
for (g in groups) {
pos_graph <- igraphs_all_pos_s[[g]]
neg_graph <- igraphs_all_neg_s[[g]]
amount_of_v_and_e_with_singltons <- rbind(
amount_of_v_and_e_with_singltons,
data.frame(
Group = g,
V_pos = length(V(pos_graph)), # Number of nodes in positive network
E_pos = length(E(pos_graph)), # Number of edges in positive network
dens_pos = round(edge_density(pos_graph), 4), # Edge density of positive network
V_neg = length(V(neg_graph)), # Number of nodes in negative network
E_neg = length(E(neg_graph)), # Number of edges in negative network
dens_neg = round(edge_density(neg_graph), 4) # Edge density of negative network
)
)
}
# Set row names as group names and remove redundant Group column
rownames(amount_of_v_and_e_with_singltons) <- amount_of_v_and_e_with_singltons$Group
amount_of_v_and_e_with_singltons$Group <- NULL
# Repeat network summary statistics calculation WITHOUT singleton nodes included
# Initialize empty data frame for summary statistics without singletons
amount_of_v_and_e_without_singltons <- data.frame(
Group = character(),
V_pos = integer(),
E_pos = integer(),
dens_pos = numeric(),
V_neg = integer(),
E_neg = integer(),
dens_neg = numeric(),
stringsAsFactors = FALSE
)
# Loop through each group and calculate stats for igraph objects without added singletons
for (g in groups) {
pos_graph <- igraphs_all[[g]]$positive
neg_graph <- igraphs_all[[g]]$negative
amount_of_v_and_e_without_singltons <- rbind(
amount_of_v_and_e_without_singltons,
data.frame(
Group = g,
V_pos = length(V(pos_graph)),
E_pos = length(E(pos_graph)),
dens_pos = round(edge_density(pos_graph), 4),
V_neg = length(V(neg_graph)),
E_neg = length(E(neg_graph)),
dens_neg = round(edge_density(neg_graph), 4)
)
)
}
# Set row names and remove Group column for the data frame without singletons
rownames(amount_of_v_and_e_without_singltons) <- amount_of_v_and_e_without_singltons$Group
amount_of_v_and_e_without_singltons$Group <- NULL
# Initialize empty data frames to store mean shortest path lengths
sp_avg_pos <- data.frame(bact = character(), mean_distance = numeric())
sp_avg_neg <- data.frame(bact = character(), mean_distance = numeric())
# Initialize empty data frames to store network diameters
diameter_pos <- data.frame(bact = character(), diameter = numeric())
diameter_neg <- data.frame(bact = character(), diameter = numeric())
# Initialize empty data frame to store global clustering coefficients
cc_df <- data.frame(bact = character(), CC_global_pos = numeric(), CC_global_neg = numeric())
# Initialize empty data frames to store average node degrees
avg_degree_pos <- data.frame(bact = character(), avg_degree = numeric())
avg_degree_neg <- data.frame(bact = character(), avg_degree = numeric())
# Initialize empty data frames to store average edge weights (correlation strengths)
avg_weight_pos <- data.frame(bact = character(), avg_weight = numeric())
avg_weight_neg <- data.frame(bact = character(), avg_weight = numeric())
# Initialize empty data frames to store average neighborhood connectivity
avg_nnc_pos <- data.frame(bact = character(), avg_nnc = numeric())
avg_nnc_neg <- data.frame(bact = character(), avg_nnc = numeric())
for (bact in names(igraphs_all)) {
ig_pos <- igraphs_all[[bact]][[1]] # Positive network igraph object
ig_neg <- igraphs_all[[bact]][[2]] # Negative network igraph object
# Mean shortest path lengths
sp_avg_pos <- rbind(sp_avg_pos, data.frame(
bact = bact,
mean_distance = mean_distance(ig_pos,
weights = 1/E(ig_pos)$weight,
directed = FALSE,
unconnected = TRUE)
))
sp_avg_neg <- rbind(sp_avg_neg, data.frame(
bact = bact,
mean_distance = mean_distance(ig_neg,
weights = abs(1/E(ig_neg)$weight),
directed = FALSE,
unconnected = TRUE)
))
# Network diameters
diameter_pos <- rbind(diameter_pos, data.frame(
bact = bact,
diameter = diameter(ig_pos,
weights = 1/E(ig_pos)$weight,
directed = FALSE,
unconnected = TRUE)
))
diameter_neg <- rbind(diameter_neg, data.frame(
bact = bact,
diameter = diameter(ig_neg,
weights = 1/abs(E(ig_neg)$weight),
directed = FALSE,
unconnected = TRUE)
))
# Global clustering coefficient
cc_pos <- igraph::transitivity(ig_pos, type = "global", vids = V(ig_pos),
weights = 1/abs(E(ig_pos)$weight), isolates = "zero")
cc_neg <- igraph::transitivity(ig_neg, type = "global", vids = V(ig_neg),
weights = 1/abs(E(ig_neg)$weight), isolates = "zero")
cc_df <- rbind(cc_df, data.frame(
bact = bact,
CC_global_pos = cc_pos,
CC_global_neg = cc_neg
))
# Average degree
avg_degree_pos <- rbind(avg_degree_pos, data.frame(
bact = bact,
avg_degree = mean(degree(ig_pos))
))
avg_degree_neg <- rbind(avg_degree_neg, data.frame(
bact = bact,
avg_degree = mean(degree(ig_neg))
))
# Average edge weight (correlation strength)
avg_weight_pos <- rbind(avg_weight_pos, data.frame(
bact = bact,
avg_weight = mean(E(ig_pos)$weight)
))
avg_weight_neg <- rbind(avg_weight_neg, data.frame(
bact = bact,
avg_weight = mean(E(ig_neg)$weight)
))
# Average neighborhood connectivity
avg_nnc_pos <- rbind(avg_nnc_pos, data.frame(
bact = bact,
avg_nnc = mean(influential::neighborhood.connectivity(ig_pos))
))
avg_nnc_neg <- rbind(avg_nnc_neg, data.frame(
bact = bact,
avg_nnc = mean(influential::neighborhood.connectivity(ig_neg))
))
}
# ----------------------------------------
# Step 6: Generate Shuffled Networks and Compare the Jaccard Indexes (Fig. 5S)
# ----------------------------------------
set.seed(12) # Set seed for reproducibility
# Function to shuffle edges of a network while preserving degree sequence,
# then randomly reassign edge weights by shuffling original weights
shuffle_fun <- function(igraph) {
# Ensure the input is an igraph object
if (is.null(igraph) || !inherits(igraph, "igraph")) stop("shuffle_fun: input must be an igraph object.")
g1 = igraph::rewire(igraph, with = keeping_degseq(niter = ecount(igraph) * 10))
# Shuffle weights if present and sized correctly
w <- igraph::E(igraph)$weight
if (!is.null(w) && length(w) == igraph::ecount(g1)) {
igraph::E(g1)$weight <- sample(w, replace = FALSE)
} else if (is.null(w)) {
warning("shuffle_fun: no weights on original graph, leaving weights unchanged.")
} else {
warning("shuffle_fun: weight length mismatch, leaving weights unchanged.")
}
return(g1)
}
# Function to combine negative and positive networks of each bacterial strain
process_network <- function(positive_igraph, negative_igraph) {
combined_igraph = positive_igraph %u% negative_igraph
# Handling missing edge weights by replacing NA with zero
E(combined_igraph)$weight_1[is.na(E(combined_igraph)$weight_1)] <- 0
E(combined_igraph)$weight_2[is.na(E(combined_igraph)$weight_2)] <- 0
# Taking weights from positive and negative networks
E(combined_igraph)$weight <- E(combined_igraph)$weight_1 + E(combined_igraph)$weight_2
return(combined_igraph)
}
# Function to combine positive and negative networks, then generate multiple shuffled versions
process_and_shuffle <- function(pos_igraph, neg_igraph, num_shuffles) {
# Check that the input variables pos_igraph and neg_igraph are igraph objects
if (is.null(pos_igraph) || !inherits(pos_igraph, "igraph"))
stop("process_and_shuffle: 'positive_igraph' must be an igraph.")
if (is.null(neg_igraph) || !inherits(neg_igraph, "igraph"))
stop("process_and_shuffle: 'negative_igraph' must be an igraph.")
combined_igraph <- process_network(pos_igraph, neg_igraph)
if (!is.numeric(num_shuffles))
stop("process_and_shuffle: 'num_shuffles' must be numeric.")
shuffled_networks <- replicate(num_shuffles, shuffle_fun(combined_igraph), simplify = FALSE)
return(shuffled_networks)
}
# Apply the process_and_shuffle function to all groups.
# Here 10 shuffles are performed per group for runtime reasons (in the analysis for the paper 1000 shuffles were used)
all_shuffled_networks <- mapply(
FUN = process_and_shuffle,
pos_igraph = igraphs_all_pos_s,
neg_igraph = igraphs_all_neg_s,
num_shuffles = 10,
SIMPLIFY = FALSE
)
# Function to compare Jaccard similarity between random (shuffled) and empirical networks
# Loops over pairs of networks, skipping self-comparisons.
# For each pair, computes Jaccard index based on overlapping positive and negative edges
jaccard_rnd_vs_real_networks <- function(list_of_rnd, list_of_true){
# Check that the input list is not empty
if (length(list_of_true) == 0 || length(list_of_rnd) == 0) stop("jaccard_rnd_vs_real_networks: input lists cannot be empty.")
res <- c()
bact <- c()
for (set_2_num in c(1:length(list_of_true))){ # iterate over "true" (empirical) networks
for (set_1_num in c(1:length(list_of_rnd))){ # iterate over shuffled networks
if (set_2_num!=set_1_num){
ran_res <- c()
for (ran_net in c(1:length(list_of_rnd[[set_1_num]]))){ # iterate over shuffled replicates
rnd=list_of_rnd[[set_1_num]][[ran_net]]
# Separate positive and negative edges by weight sign
g_pos <- delete.edges(rnd, which(E(rnd)$weight <0))
g_neg <- delete.edges(rnd, which(E(rnd)$weight >0))
# Count common edges between shuffled and true networks for positive and negative graphs
pos_common=length(E(g_pos %s% list_of_true[[set_2_num]][[1]]))
neg_common=length(E(g_neg %s% list_of_true[[set_2_num]][[2]]))
common= pos_common+neg_common
bact <- append(bact,paste0("rnd_",set_1_num,"_","true_",set_2_num))
# Calculate Jaccard index for combined edges
jacc <-common/(length(E(g_pos))+length(E(g_neg))+length(E(list_of_true[[set_2_num]][[1]]))+length(E(list_of_true[[set_2_num]][[2]]))-common)
ran_res <- append(ran_res,round(jacc, digits=3))}
res <- append(res,ran_res)}
}}
return (data.frame("bact"=bact, "jacc"=res))
}
# Combine positive and negative igraphs (with singletons) into list pairs for input to comparison
igraphs_all_s <- Map(function(pos, neg) list(pos, neg), igraphs_all_pos_s, igraphs_all_neg_s)
# Run Jaccard similarity comparison between shuffled and empirical networks
Jaccard_rnd_network <- jaccard_rnd_vs_real_networks(all_shuffled_networks, igraphs_all_s)
# Aggregate results by each pair of networks (random vs true)
jacc_rnd_pos_and_neg1 <- aggregate(jacc ~ bact, Jaccard_rnd_network, paste, collapse = ", ")
# Function to compute z-scores and p-values comparing empirical Jaccard indices
# against distributions of Jaccard indices from shuffled networks
z_score_of_jaccard_rnd <- function(jaccard_rnd_df, jaccard_true_df) {
res_z <- matrix(NA, nrow = nrow(jaccard_true_df), ncol = ncol(jaccard_true_df),
dimnames = list(rownames(jaccard_true_df), colnames(jaccard_true_df)))
res_p <- res_z
plot_list <- list()
# Mapping of string replacements for nicer plot titles
rep_str <- c('1'='WT','2'='unevolved','3'='evolved',
'4'='evolved-WT', '5'='mut', 'rnd'='R', 'true'='T')
for (i in seq_len(nrow(jaccard_rnd_df))) {
row_name <- jaccard_rnd_df[i, 1]
j_rnd_vector <- as.numeric(unlist(strsplit(jaccard_rnd_df[i, 2], ", ")))
m <- mean(j_rnd_vector)
s <- sd(j_rnd_vector)
# Extract indices from the string (format "rnd_X_true_Y")
bact_couple <- unlist(strsplit(row_name, "_"))
bact_true <- as.integer(bact_couple[4])
bact_rnd <- as.integer(bact_couple[2])
jacc_true <- jaccard_true_df[bact_true, bact_rnd]
z_val <- (jacc_true - m) / s
p_val <- pnorm(z_val, mean = m, sd = s, lower.tail = FALSE)
res_z[bact_true, bact_rnd] <- round(z_val, 3)
res_p[bact_true, bact_rnd] <- p_val
# Create nicer label for plots by replacing codes
bact_couple_names <- stringr::str_replace_all(row_name, rep_str)
d <- data.frame(Jaccard = j_rnd_vector)
plot <- ggplot(d, aes(x = Jaccard)) +
geom_histogram(binwidth = 0.0005, fill = "dodgerblue", alpha = 0.5, boundary = 0) +
geom_vline(xintercept = jacc_true, linewidth = 1.5, color = "red") +
xlab(NULL) + ylab(NULL) + ggtitle(bact_couple_names) +
theme_classic() + theme(text = element_text(size = 12))
plot_list[[length(plot_list) + 1]] <- plot
}
return(list(plots = plot_list, z_scores = res_z, p_values = res_p))
}
# Run z-score and p-value calculations and generate density plots comparing shuffled vs empirical Jaccard indices
jacc_rnd_vs_true_results <- z_score_of_jaccard_rnd(jacc_rnd_pos_and_neg1,Jaccard_networks)
#Fig 5S
s <- arrangeGrob(grobs = jacc_rnd_vs_true_results[[1]])
figure <- annotate_figure(s, left = textGrob("Count", rot = 90, vjust = 1, gp = gpar(cex = 1.7)),
bottom = textGrob("Jaccard Values", gp = gpar(cex = 1.7)),fig.lab.size=0.5,fig.lab.face = "bold")
# ----------------------------------------
# Step 7: Create Multilayer Networks Using the "emln" Package
# ----------------------------------------
# Note: The emln package requires positive weights for proper functionality,
# so the absolute value of the negative edge weights is used.
# Initialize list to store multilayer networks for each group
multilayer_networks <- list()
# Loop over each bacterial group to build multilayer networks
for (group in names(igraphs_all_s)) {
pos_graph <- igraphs_all_s[[group]][[1]]
neg_graph <- igraphs_all_s[[group]][[2]]
# Convert negative edge weights to positive by taking absolute value
E(neg_graph)$weight <- abs(E(neg_graph)$weight) # Ensure positive weights for negative layer
# Extract adjacency matrices weighted by edge weights for both layers
pos_mat <- as.matrix(pos_graph, attr = "weight", sparse = FALSE)
neg_mat <- as.matrix(neg_graph, attr = "weight", sparse = FALSE)
# Create multilayer network object from positive and negative adjacency matrices
multilayer_networks[[group]] <- emln::create_multilayer_network(
list_of_layers = list(pos_mat, neg_mat),bipartite = FALSE,directed = FALSE)
}
# ----------------------------------------
# Step 8: Modularity Using 'Infomap' Package
# ----------------------------------------
# This step performs community detection on multilayer networks using the Infomap algorithm.
# Note: This analysis is computationally intensive and is recommended to run on an HPC cluster.
# Ensure 'Infomap' package is installed and functional (use check_infomap() to verify).
# Optionally, you can skip running the analysis here and load previously saved results at the end of this section.
# Function to run Infomap community detection on a multilayer network object
modularity_using_infomap_multi <- function(multilayer_net) {
# Make sure the input is a list, and that the needed fields exist
if (is.null(multilayer_net) || !is.list(multilayer_net))
stop("modularity_using_infomap_multi: 'multilayer_net' must be a list.")
req <- c("extended_ids", "nodes", "layers")
if (!all(req %in% names(multilayer_net)))
stop(sprintf("modularity_using_infomap_multi: missing fields in 'multilayer_net': %s",
paste(setdiff(req, names(multilayer_net)), collapse = ", ")))
# Create the multilayer object compatible with Infomap
multi_obj <- infomapecology::create_multilayer_object(
extended = multilayer_net$extended_ids,
nodes = multilayer_net$nodes,
layers = multilayer_net$layers,
intra_output_extended = FALSE
)
multi_obj$inter <- NULL # Remove inter-layer links if none exist (optional)
#Run the Infomap multilayer algorithm with specified parameters:
# - relax = TRUE: allows node state relaxations across layers
# - flow_model = 'undirected': undirected flow assumed
# - silent = TRUE: suppresses console output during run
# - trials = 100: number of algorithm trials to optimize results
# - seed = 1234: fixed seed for reproducibility
# - temporal_network = FALSE: treat as static multilayer network
multi_info <- run_infomap_multilayer(
M = multi_obj,
relax = TRUE,
flow_model = 'undirected',
silent = TRUE,
trials = 100,
seed = 1234,
temporal_network = FALSE
)
# Process module assignments returned by Infomap
module_list <- multi_info$modules %>%
mutate(state_node = seq_len(n())) %>% # Add sequential node ID
arrange(module) %>% # Sort by module number
select(-starts_with(".")) # Remove columns starting with '.'
# Return list containing:
# - full Infomap output object
# - multilayer object used
# - cleaned modules data frame
return(list(info = multi_info, obj = multi_obj, modules = module_list))
}
# Apply the modularity detection function to all multilayer networks
modularity_results <- lapply(multilayer_networks, modularity_using_infomap_multi)
# Extract separate components from the results for each bacterial group:
# 1) Infomap output objects ('info'):
WT_info_object_multi <- modularity_results[["WT"]]$info
unevo_info_object_multi <- modularity_results[["unevo"]]$info
evo_info_object_multi <- modularity_results[["evo"]]$info
evoWT_info_object_multi <- modularity_results[["evoWT"]]$info
mut_info_object_multi <- modularity_results[["mut"]]$info
# 2) Multilayer network objects ('obj') that were analyzed by Infomap
WT_multi_obj_multi <- modularity_results[["WT"]]$obj
unevo_multi_obj_multi <- modularity_results[["unevo"]]$obj
evo_multi_obj_multi <- modularity_results[["evo"]]$obj
evoWT_multi_obj_multi <- modularity_results[["evoWT"]]$obj
mut_multi_obj_multi <- modularity_results[["mut"]]$obj
# 3) Module assignment data frames for each group (note: singletons may have NA values)
all_modules <- lapply(modularity_results, function(x) x$modules)
WT_modules <- modularity_results[["WT"]]$modules
unevo_modules <- modularity_results[["unevo"]]$modules
evo <- modularity_results[["evo"]]$modules
evoWT_modules <- modularity_results[["evoWT"]]$modules
mut_modules <- modularity_results[["mut"]]$modules
# ----------------------------------------
# Optional: Load precomputed Infomap modularity results instead of running above (to save time)
# 1) Load Infomap output objects ('info')
WT_info_object_multi <- read_rds("Data/multi_networks_results/WT_info_object.RData")
unevo_info_object_multi <- read_rds("Data/multi_networks_results/unevo_info_object.RData")
evo_info_object_multi <- read_rds("Data/multi_networks_results/evo_info_object.RData")
evoWT_info_object_multi <- read_rds("Data/multi_networks_results/evoWT_info_object.RData")
mut_info_object_multi <- read_rds("Data/multi_networks_results/mut_info_object.RData")
# 2) Load multilayer network objects ('obj')
WT_multi_obj_multi <- read_rds("Data/multi_networks_results/WT_multi_object.RData")
unevo_multi_obj_multi <- read_rds("Data/multi_networks_results/evo_multi_object.RData")
evo_multi_obj_multi <- read_rds("Data/multi_networks_results/evo_multi_object.RData")
evoWT_multi_obj_multi <- read_rds("Data/multi_networks_results/evoWT_multi_object.RData")
mut_multi_obj_multi <- read_rds("Data/multi_networks_results/mut_multi_object.RData")
# 3) Load module assignment data frames (note removal of hidden columns starting with '.')
WT_modules <- read_csv("Data/multi_networks_results/WT_module_list.csv") %>%select(-starts_with("."))
unevo_modules <- read_csv("Data/multi_networks_results/unevo_module_list.csv")%>%select(-starts_with("."))
evo_modules <- read_csv("Data/multi_networks_results/evo_module_list.csv")%>%select(-starts_with("."))
evoWT_modules <- read_csv("Data/multi_networks_results/evoWT_module_list.csv")%>%select(-starts_with("."))
mut_modules <- read_csv("Data/multi_networks_results/mut_module_list.csv")%>%select(-starts_with("."))
# ----------------------------------------
# ----------------------------------------
# Step 9: Assigning Node Roles in Multilayer Networks
# ----------------------------------------
# Node roles classification is based on two key metrics from Guimer?? & Amaral (2005):
# 1. Within-module degree (Z): measures how well a node is connected to others in its own module.
# 2. Participation coefficient (C): measures how a node's connections are distributed among different modules.
# Nodes are categorized into roles such as peripheral, connector, module hub, or network hub based on thresholds of Z and C.
# Function to calculate the Z-score for a given node 'i'
# Z-score quantifies how the node's within-module degree compares to the average within-module degree in that module
z_score_multi <- function(i, modules, stats) {
# Ensure the input datasets are data frames and that the needed columns exist
if (is.null(modules) || !is.data.frame(modules)) stop("z_score_multi: 'modules' must be a data.frame.")
if (is.null(stats) || !is.data.frame(stats)) stop("z_score_multi: 'stats' must be a data.frame.")
req_mod <- c("state_node","module","k_m")
req_sta <- c("module","k_m_avg","k_m_sd")
if (!all(req_mod %in% names(modules))) stop("z_score_multi: 'modules' must contain columns: state_node, module, k_m.")
if (!all(req_sta %in% names(stats))) stop("z_score_multi: 'stats' must contain columns: module, k_m_avg, k_m_sd.")
m_i <- modules$module[modules$state_node == i]
k_m_i <- modules$k_m[modules$state_node == i]
avg_k <- stats$k_m_avg[stats$module == m_i]
sd_k <- stats$k_m_sd[stats$module == m_i]
return ((k_m_i - avg_k)/sd_k)
}