This report explores an ecological food web from the transitional shoreline-forest region of Lake Malawi. The aim is not only to draw a network, but to interpret the ecological structure of the system: which organisms form the basal resource base, which species transfer energy through the aquatic food web, which consumers connect the lake shoreline to the forest margin, and which species act as highly connected hubs or bridge species.
The network is treated as a directed food web. In this analysis, each edge follows this convention:
consumer -> resource
This means that an arrow or link begins at the organism doing the
consuming and points toward the resource, prey, or food item being used.
Under this convention, a high out_degree means a species
uses many resources, while a high in_degree means a species
is used by many consumers.
The analysis uses tidyverse for data cleaning,
igraph and tidygraph for network analysis, and
plotly for the interactive 3D figures. The DT
package is used to make tables searchable and scrollable in the HTML
output.
library(tidyverse)
library(igraph)
library(tidygraph)
library(plotly)
library(DT)
The food-web data are read from a CSV file. The code is written to be
flexible: it looks for common column names such as
consumer, resource, predator,
prey, from, and to. The
interaction strength column is also detected automatically if it is
named interaction_strength, strength, or
weight.
Species names are cleaned by replacing spaces with underscores. This makes them easier to use as graph node names.
file_path <- "C:/Users/Hp User/Downloads/Ecological networks/lake_malawi_shoreline_foodweb_clean.csv"
fw_raw <- readr::read_csv(file_path, show_col_types = FALSE)
consumer_col <- intersect(
names(fw_raw),
c("consumer", "Consumer", "predator", "Predator", "from", "From")
)[1]
resource_col <- intersect(
names(fw_raw),
c("resource", "Resource", "prey", "Prey", "to", "To")
)[1]
strength_col <- intersect(
names(fw_raw),
c("interaction_strength", "Interaction_strength", "strength", "weight", "Weight")
)[1]
if (is.na(consumer_col) || is.na(resource_col)) {
stop("Could not find consumer/resource columns. Rename them to consumer and resource.")
}
fw <- fw_raw %>%
transmute(
consumer = str_replace_all(as.character(.data[[consumer_col]]), "\\s+", "_"),
resource = str_replace_all(as.character(.data[[resource_col]]), "\\s+", "_"),
interaction_strength = if (is.na(strength_col)) 1 else as.numeric(.data[[strength_col]])
) %>%
filter(!is.na(consumer), !is.na(resource), consumer != "", resource != "") %>%
mutate(
interaction_strength = replace_na(interaction_strength, 1),
interaction_strength = if_else(interaction_strength <= 0, 0.001, interaction_strength)
)
glimpse(fw)
## Rows: 215
## Columns: 3
## $ consumer <chr> "Tropodiaptomus_cunningtoni", "Diaphanosoma_excis…
## $ resource <chr> "Phytoplankton", "Phytoplankton", "Phytoplankton"…
## $ interaction_strength <dbl> 0.85, 0.70, 0.65, 0.80, 0.30, 0.15, 0.40, 0.70, 0…
The cleaned table contains one row per trophic interaction. The
consumer column gives the organism using the resource, the
resource column gives the prey or food resource, and
interaction_strength gives the relative strength of that
feeding relationship.
To make the network biologically informative, species are grouped into trophic guilds. These guilds are based on broad ecological roles: basal resources, zooplankton, benthic invertebrates, cichlid fish, other fish, waterbirds, reptiles, semi-aquatic mammals, and terrestrial mammals.
The same species are also grouped into three habitat zones:
Aquatic/littoral: organisms mainly connected to the
lake and nearshore littoral zone.Shoreline interface: organisms that move between water
and land, such as waterbirds, crocodiles, reptiles, otters, and
hippos.Forest/terrestrial: land-based mammals and forest-edge
species.species <- tibble(name = sort(unique(c(fw$consumer, fw$resource)))) %>%
mutate(
guild = case_when(
str_detect(name, regex("Phytoplankton|algae|macrophytes|detritus|vegetation|periphyton", ignore_case = TRUE)) ~ "Basal resources",
str_detect(name, regex("Chaoborus|Mesocyclops|Tropodiaptomus|Diaphanosoma|Bosmina|Rotifera|zooplankton", ignore_case = TRUE)) ~ "Zooplankton",
str_detect(name, regex("Chironomus|Oligochaeta|Caridina|Ephemer|Trichoptera|Melanoides|Bulinus|Coelatura|Dragonfly|Potamonautes|benthos|larvae", ignore_case = TRUE)) ~ "Benthos/invertebrates",
str_detect(name, regex("Pseudo|Labeot|Melanoc|Metria|Labidoc|Copadich|Rhamph|Sciaeno|Nimboch|Oreochromis|Tilapia|cichlid", ignore_case = TRUE)) ~ "Cichlid fish",
str_detect(name, regex("Engraulicy|Synod|Clarias|Bagrus|fish", ignore_case = TRUE)) ~ "Other fish",
str_detect(name, regex("eagle|kingfisher|heron|cormorant|Hamerkop|stork|jacana|openbill|harrier|bird", ignore_case = TRUE)) ~ "Waterbirds",
str_detect(name, regex("crocodile|Monitor|python|reptile", ignore_case = TRUE)) ~ "Reptiles",
str_detect(name, regex("otter|Hippop|hippo", ignore_case = TRUE)) ~ "Semi-aquatic mammals",
TRUE ~ "Terrestrial mammals"
),
habitat = case_when(
guild %in% c("Basal resources", "Zooplankton", "Benthos/invertebrates", "Cichlid fish", "Other fish") ~ "Aquatic/littoral",
guild %in% c("Waterbirds", "Reptiles", "Semi-aquatic mammals") ~ "Shoreline interface",
TRUE ~ "Forest/terrestrial"
),
trophic_band = case_when(
guild == "Basal resources" ~ 1,
guild %in% c("Zooplankton", "Benthos/invertebrates") ~ 2,
guild %in% c("Cichlid fish", "Other fish") ~ 3,
guild %in% c("Waterbirds", "Reptiles", "Semi-aquatic mammals") ~ 4,
TRUE ~ 5
)
)
guild_pal <- c(
"Basal resources" = "#5E9F3D",
"Zooplankton" = "#1B9E77",
"Benthos/invertebrates" = "#B87923",
"Cichlid fish" = "#3A8DDE",
"Other fish" = "#1D5F99",
"Waterbirds" = "#7B70D6",
"Reptiles" = "#D35C37",
"Semi-aquatic mammals" = "#C84A7A",
"Terrestrial mammals" = "#5F5F5A"
)
habitat_pal <- c(
"Aquatic/littoral" = "#2F80B7",
"Shoreline interface" = "#C25D3B",
"Forest/terrestrial" = "#4F7E3A"
)
DT::datatable(
species,
rownames = FALSE,
options = list(pageLength = 10, scrollX = TRUE)
)
This grouping step is important because the network should show ecological meaning, not only mathematical structure. The guild and habitat categories allow the figures to show how energy moves from basal resources through aquatic consumers and then into shoreline and terrestrial consumers.
The graph is built with igraph. Each species is a node,
and each trophic interaction is a directed link from consumer to
resource.
g_ig <- graph_from_data_frame(
fw %>% select(from = consumer, to = resource, interaction_strength),
directed = TRUE,
vertices = species %>% select(name)
)
E(g_ig)$weight <- E(g_ig)$interaction_strength
g_ig
## IGRAPH 867cae7 DNW- 65 215 --
## + attr: name (v/c), interaction_strength (e/n), weight (e/n)
## + edges from 867cae7 (vertex names):
## [1] Tropodiaptomus_cunningtoni->Phytoplankton
## [2] Diaphanosoma_excisum ->Phytoplankton
## [3] Bosmina_longirostris ->Phytoplankton
## [4] Rotifera_spp ->Phytoplankton
## [5] Oreochromis_karongae ->Phytoplankton
## [6] Engraulicypris_sardella ->Phytoplankton
## [7] Caridina_nilotica ->Phytoplankton
## [8] Coelatura_mussel ->Phytoplankton
## + ... omitted several edges
The first summary table describes the whole food web. These values help describe the overall complexity of the network.
network_summary <- tibble(
species_count = gorder(g_ig),
interaction_count = gsize(g_ig),
connectance = edge_density(g_ig, loops = FALSE),
mean_degree = mean(degree(g_ig, mode = "all")),
mean_interaction_strength = mean(E(g_ig)$interaction_strength),
reciprocity = reciprocity(g_ig, ignore.loops = TRUE),
weak_components = components(g_ig, mode = "weak")$no
)
DT::datatable(
network_summary,
rownames = FALSE,
options = list(dom = "t")
)
Connectance describes the proportion of possible links
that are actually present. Food webs are usually sparse rather than
fully connected, because not every species interacts with every other
species. Mean degree describes the average number of links
per species. Weak components indicates whether the network
is one connected system or split into separate pieces when edge
direction is ignored.
The next step calculates centrality metrics for each species. These values help identify likely hubs, bridge species, basal resources, and top consumers.
node_metrics <- tibble(
name = V(g_ig)$name,
out_degree = degree(g_ig, mode = "out"),
in_degree = degree(g_ig, mode = "in"),
degree_all = degree(g_ig, mode = "all"),
strength_all = strength(g_ig, mode = "all", weights = E(g_ig)$weight),
betweenness = betweenness(
g_ig,
directed = TRUE,
normalized = TRUE,
weights = 1 / E(g_ig)$weight
),
pagerank = page_rank(
g_ig,
directed = TRUE,
weights = E(g_ig)$weight
)$vector
) %>%
left_join(species, by = "name") %>%
mutate(
node_size = scales::rescale(degree_all, to = c(5, 22)),
role = case_when(
out_degree == 0 & in_degree > 0 ~ "Basal / prey-only resource",
out_degree > 0 & in_degree == 0 ~ "Top consumer",
betweenness >= quantile(betweenness, 0.90, na.rm = TRUE) ~ "Bridge / connector",
degree_all >= quantile(degree_all, 0.90, na.rm = TRUE) ~ "Hub",
TRUE ~ "Intermediate"
)
)
top_hubs <- node_metrics %>%
arrange(desc(degree_all), desc(strength_all)) %>%
select(name, guild, habitat, role, out_degree, in_degree, degree_all, strength_all, betweenness) %>%
slice_head(n = 15)
top_bridges <- node_metrics %>%
arrange(desc(betweenness), desc(degree_all)) %>%
select(name, guild, habitat, role, out_degree, in_degree, degree_all, strength_all, betweenness) %>%
slice_head(n = 15)
Hub species have many direct links. In this food web, a hub may be a resource used by many consumers, a generalist consumer that uses many prey, or a species that plays both roles.
DT::datatable(
top_hubs,
rownames = FALSE,
options = list(pageLength = 15, scrollX = TRUE)
)
Bridge species have high betweenness centrality. These species sit on many shortest paths through the network, meaning they may connect different ecological compartments. In this shoreline-forest system, bridge species are especially important because they may link aquatic production to shoreline and terrestrial consumers.
DT::datatable(
top_bridges,
rownames = FALSE,
options = list(pageLength = 15, scrollX = TRUE)
)
Important caution: centrality does not prove that a species is a true ecological keystone. It identifies structural importance in the network. A species should be called a candidate keystone, hub, or bridge species unless there is additional evidence from abundance, biomass, removal experiments, or long-term ecological observation.
This table summarizes interactions by guild. It shows which types of consumers are linked to which types of resources and gives the total interaction strength for each guild-to-guild pathway.
guild_summary <- fw %>%
left_join(
species %>% select(consumer = name, consumer_guild = guild, consumer_habitat = habitat),
by = "consumer"
) %>%
left_join(
species %>% select(resource = name, resource_guild = guild, resource_habitat = habitat),
by = "resource"
) %>%
group_by(consumer_guild, resource_guild) %>%
summarise(
interaction_count = n(),
total_strength = sum(interaction_strength, na.rm = TRUE),
mean_strength = mean(interaction_strength, na.rm = TRUE),
.groups = "drop"
) %>%
arrange(desc(total_strength))
DT::datatable(
guild_summary,
rownames = FALSE,
options = list(pageLength = 12, scrollX = TRUE)
)
This table is useful for writing the ecological interpretation. For example, strong pathways from basal resources to zooplankton or benthic invertebrates indicate the lower food-web foundation. Strong pathways from fish to waterbirds, reptiles, or semi-aquatic mammals show the transfer of aquatic production into the shoreline-interface community.
The 3D layout uses two ideas:
x and y positions come from a
force-directed network layout, so species with similar network
connections tend to appear nearer to one another.z position is based on trophic band, so basal
resources appear lower and higher consumers appear higher.This makes the 3D plot ecological rather than only decorative. The vertical axis represents trophic organization.
set.seed(42)
z_spacing <- 2.2
xy_layout <- layout_with_fr(g_ig, weights = E(g_ig)$weight)
nodes_3d <- node_metrics %>%
mutate(
x = xy_layout[, 1],
y = xy_layout[, 2],
z = trophic_band * z_spacing + runif(n(), -0.16, 0.16),
hover_text = paste0(
"<b>", name, "</b>",
"<br>Guild: ", guild,
"<br>Habitat: ", habitat,
"<br>Role: ", role,
"<br>Diet breadth/out-degree: ", out_degree,
"<br>Vulnerability/in-degree: ", in_degree,
"<br>Total degree: ", degree_all,
"<br>Betweenness: ", round(betweenness, 4)
)
)
edge_tbl <- as_data_frame(g_ig, what = "edges") %>%
rename(consumer = from, resource = to) %>%
left_join(
nodes_3d %>% select(consumer = name, x_from = x, y_from = y, z_from = z),
by = "consumer"
) %>%
left_join(
nodes_3d %>% select(resource = name, x_to = x, y_to = y, z_to = z),
by = "resource"
)
make_edge_trace <- function(edge_data, colour = "rgba(0,0,0,0.45)", width = 3) {
list(
x = as.vector(rbind(edge_data$x_from, edge_data$x_to, NA)),
y = as.vector(rbind(edge_data$y_from, edge_data$y_to, NA)),
z = as.vector(rbind(edge_data$z_from, edge_data$z_to, NA)),
line = list(color = colour, width = width)
)
}
edge_trace <- make_edge_trace(edge_tbl)
This figure shows the full food web in 3D. The height of each node represents its trophic band. Colour represents guild, and node size represents total connectedness. Larger nodes are more connected and may be important hubs in the food-web structure.
p_3d_trophic <- plot_ly() %>%
add_trace(
x = edge_trace$x,
y = edge_trace$y,
z = edge_trace$z,
type = "scatter3d",
mode = "lines",
line = edge_trace$line,
hoverinfo = "none",
showlegend = FALSE
) %>%
add_trace(
data = nodes_3d,
x = ~x,
y = ~y,
z = ~z,
type = "scatter3d",
mode = "markers+text",
text = ~ifelse(degree_all >= quantile(degree_all, 0.80, na.rm = TRUE), name, ""),
textposition = "top center",
hovertext = ~hover_text,
hoverinfo = "text",
color = ~guild,
colors = guild_pal,
marker = list(
size = ~node_size,
opacity = 0.92,
line = list(color = "black", width = 0.7)
)
) %>%
layout(
title = list(
text = "3D Lake Malawi shoreline food web<br><sup>Height = trophic band; colour = guild; size = connectedness</sup>"
),
scene = list(
xaxis = list(title = "Network position X", showgrid = FALSE, zeroline = FALSE),
yaxis = list(title = "Network position Y", showgrid = FALSE, zeroline = FALSE),
zaxis = list(
title = "Trophic band",
tickvals = (1:5) * z_spacing,
ticktext = c("Basal", "Invertebrates", "Fish", "Interface predators", "Terrestrial"),
range = c(0.5 * z_spacing, 5.6 * z_spacing)
),
camera = list(eye = list(x = 1.7, y = 1.5, z = 1.35))
),
legend = list(title = list(text = "Guild"))
)
p_3d_trophic
Interpretation. This figure emphasizes the vertical trophic structure of the web. Basal resources occur in the lower band, invertebrate consumers occupy the next level, fish form a major middle layer, and shoreline-interface predators occur higher in the plot. Highly connected nodes are visually larger, allowing likely hubs to be identified quickly. Because the graph is interactive, hovering over a species reveals its guild, habitat zone, degree values, and betweenness score.
This figure uses the same coordinates as Figure 1, but it changes the ecological question. Instead of colouring nodes by guild, nodes are coloured by habitat zone. Node size is based on betweenness centrality, which highlights bridge species.
p_3d_habitat <- plot_ly() %>%
add_trace(
x = edge_trace$x,
y = edge_trace$y,
z = edge_trace$z,
type = "scatter3d",
mode = "lines",
line = list(color = "rgba(0,0,0,0.42)", width = 3),
hoverinfo = "none",
showlegend = FALSE
) %>%
add_trace(
data = nodes_3d,
x = ~x,
y = ~y,
z = ~z,
type = "scatter3d",
mode = "markers+text",
text = ~ifelse(betweenness >= quantile(betweenness, 0.85, na.rm = TRUE), name, ""),
textposition = "top center",
hovertext = ~hover_text,
hoverinfo = "text",
color = ~habitat,
colors = habitat_pal,
marker = list(
size = ~scales::rescale(betweenness + 0.001, to = c(5, 24)),
opacity = 0.94,
line = list(color = "black", width = 0.7)
)
) %>%
layout(
title = list(
text = "3D shoreline-forest transition network<br><sup>Colour = habitat; size = betweenness bridge score</sup>"
),
scene = list(
xaxis = list(title = "Network position X", showgrid = FALSE, zeroline = FALSE),
yaxis = list(title = "Network position Y", showgrid = FALSE, zeroline = FALSE),
zaxis = list(
title = "Trophic band",
tickvals = (1:5) * z_spacing,
ticktext = c("Basal", "Invertebrates", "Fish", "Interface predators", "Terrestrial"),
range = c(0.5 * z_spacing, 5.6 * z_spacing)
),
camera = list(eye = list(x = 1.7, y = 1.5, z = 1.35))
),
legend = list(title = list(text = "Habitat zone"))
)
p_3d_habitat
Interpretation. This figure is the strongest plot for the shoreline-forest question. Aquatic/littoral species show the lake-based part of the network, forest/terrestrial species show the land-based side, and shoreline-interface species show the transition zone. Large nodes in this figure are not simply the most connected species; they are bridge species with high betweenness. These species may connect aquatic production to birds, reptiles, mammals, and forest-edge consumers.
Sometimes the whole food web is too dense to explain a single species
clearly. A focal ego network solves that problem by extracting the local
network around one species. The example below uses
Nile_crocodile if it is present in the data.
plot_3d_focal_species <- function(focal_species, order = 2, mode = "all") {
if (!focal_species %in% V(g_ig)$name) {
stop(paste("Species not found:", focal_species))
}
ego <- make_ego_graph(g_ig, order = order, nodes = focal_species, mode = mode)[[1]]
ego_nodes <- nodes_3d %>%
filter(name %in% V(ego)$name)
ego_edges <- as_data_frame(ego, what = "edges") %>%
rename(consumer = from, resource = to) %>%
left_join(
ego_nodes %>% select(consumer = name, x_from = x, y_from = y, z_from = z),
by = "consumer"
) %>%
left_join(
ego_nodes %>% select(resource = name, x_to = x, y_to = y, z_to = z),
by = "resource"
)
ego_edge_trace <- make_edge_trace(
ego_edges,
colour = "rgba(0,0,0,0.55)",
width = 4
)
plot_ly() %>%
add_trace(
x = ego_edge_trace$x,
y = ego_edge_trace$y,
z = ego_edge_trace$z,
type = "scatter3d",
mode = "lines",
line = list(color = "rgba(0,0,0,0.55)", width = 4),
hoverinfo = "none",
showlegend = FALSE
) %>%
add_trace(
data = ego_nodes,
x = ~x,
y = ~y,
z = ~z,
type = "scatter3d",
mode = "markers+text",
text = ~name,
textposition = "top center",
hovertext = ~hover_text,
hoverinfo = "text",
color = ~guild,
colors = guild_pal,
marker = list(
size = ~ifelse(name == focal_species, 26, node_size),
opacity = 0.95,
line = list(
color = ~ifelse(name == focal_species, "black", "grey30"),
width = 1.2
)
)
) %>%
layout(
title = list(
text = paste0(
"3D focal network: ", focal_species,
"<br><sup>Order ", order, " ego network; mode = ", mode, "</sup>"
)
),
scene = list(
xaxis = list(title = "Network position X", showgrid = FALSE, zeroline = FALSE),
yaxis = list(title = "Network position Y", showgrid = FALSE, zeroline = FALSE),
zaxis = list(
title = "Trophic band",
tickvals = (1:5) * z_spacing,
ticktext = c("Basal", "Invertebrates", "Fish", "Interface predators", "Terrestrial"),
range = c(0.5 * z_spacing, 5.6 * z_spacing)
),
camera = list(eye = list(x = 1.7, y = 1.5, z = 1.35))
)
)
}
if ("Nile_crocodile" %in% V(g_ig)$name) {
plot_3d_focal_species("Nile_crocodile", order = 3, mode = "out")
} else {
print("Nile_crocodile was not found in this dataset.")
}
Interpretation. The focal network is useful for
explaining the feeding neighbourhood of one species. With the edge
convention used here, mode = "out" shows the resources or
prey connected outward from the focal consumer. For an apex or shoreline
predator, this can show how aquatic prey and shoreline resources
contribute to its diet network.
The Lake Malawi shoreline-forest food web can be interpreted as a connected ecotonal system. Basal aquatic resources such as phytoplankton, algae, macrophytes, and detritus form the lower trophic base. These resources support zooplankton and benthic invertebrates, which then transfer energy upward to cichlid fish and other fish. Fish and invertebrates become important pathways linking the littoral zone to waterbirds, reptiles, and semi-aquatic mammals.
The shoreline-interface species are especially important because they connect the aquatic and terrestrial sides of the food web. In the habitat-coloured 3D plot, species with high betweenness centrality should be read as structural bridges. Their position suggests that changes to these species could affect multiple parts of the system, although this should be interpreted as a network-based hypothesis rather than direct proof of keystone status.
The most informative way to discuss this network is therefore in three layers: