res_fast <- annotate_module(
res = res,
p = "p", # demo data: no module passes fdr < 0.05
pcutoff = 0.05,
ref_data = NULL,
mode = "fast",
species = "human",
biospecimen = "plasma",
phenotype = "your disease phenotype",
pubmed = TRUE,
years = 5,
provider = "openai",
llm_model = "gpt-4.1-mini",
NCBI_api_key = Sys.getenv("NCBI_API_KEY"),
llm_api_key = Sys.getenv("OPENAI_API_KEY")
)6 Function Annotation
This chapter describes Step 5 of the FeatureMAP pipeline: annotating statistically supported modules with annotate_module(), and, optionally, integrating the annotated modules into a study-level picture with synthesize_modules().
Prerequisites: Complete module identification with identify_module() and make sure your res object is already at stage "module_identified".
6.1 What annotate_module() Does
annotate_module() performs module-level biological interpretation for the modules that passed the significance filter in Step 4.
Regardless of the mode you choose, the function always starts from the same deterministic evidence, computed in R rather than by the language model:
- Metabolite annotation: Metabolites of each significant module are annotated with class and pathway metadata from both HMDB and KEGG references.
- Top and anchor metabolite selection: Module members are ranked by
0.7 * Importance + 0.3 * Node_freq, and a Gaussian mixture model selects the leading metabolites that represent the module. - Module-level statistics: Signal concentration, KEGG and HMDB pathway coverage, global direction of change, and the endogenous/exogenous origin profile are computed.
The interpretation itself is then produced by an LLM, in one of two modes.
6.1.1 Fast mode (mode = "fast")
A two-call pipeline, and the recommended default:
- LLM call 1 – from metabolite evidence only (no phenotype), the model returns
hypothesis_type,titleandfunctional_summary. - Optional PubMed retrieval – the module title and each anchor metabolite are searched separately; the retained articles are deduplicated by PMID.
- LLM call 2 – with the study context and the literature, the model returns
phenotype_relationship,confidence_labelandrationale.
6.1.2 Advanced mode (mode = "advanced")
A multi-agent debate pipeline that produces one row per surviving hypothesis instead of one row per module:
- P0 Proposer – generates 1–3 candidate biochemical hypotheses from the metabolite evidence only.
- S1 Skeptic – challenges each hypothesis on biochemical, origin, species and biospecimen grounds, without seeing the phenotype or the literature.
- P1 Proposer revision – keeps, modifies or drops each hypothesis.
- S2 / P2 – a second debate round runs only if revisions were made or major challenges remained unanswered.
- Optional PubMed – anchor-metabolite and per-hypothesis title searches, retained for the arbiter only.
- A0 Arbiter – delivers a final
accepted/rejectedverdict per hypothesis, with a confidence score and a residual-ambiguity note. - Optional synthesis – with
synthesize = TRUE, accepted hypotheses from all modules are integrated across modules (see Section 6.6).
Use fast mode for routine analyses and for a first look at your data: it is markedly cheaper and faster. Use advanced mode when a module is ambiguous, when you want to see the reasoning that supports or rejects an interpretation, or when you want a study-level synthesis across modules.
6.2 Key Parameters
| Parameter | Default | Description |
|---|---|---|
res |
required | featuremap_result returned by identify_module() |
p |
"fdr" |
Column in res$module_score used for filtering ("p" or "fdr") |
pcutoff |
0.05 |
Threshold applied to the selected significance column |
ref_data |
NULL |
Optional functional reference; a data frame with an id column, or a list with hmdb and kegg components |
mode |
"fast" |
"fast" (two-call pipeline) or "advanced" (multi-agent debate) |
species |
"human" |
Species context used in module interpretation |
biospecimen |
NULL |
Biospecimen context (e.g. plasma, serum, CSF, tissue) |
phenotype |
NULL |
Disease or phenotype context |
pubmed |
TRUE |
Whether to retrieve PubMed evidence |
years |
5 |
Time window for the literature search |
provider |
"openai" |
LLM provider: "openai", "gemini", "siliconflow" or "local" |
llm_model |
"gpt-4.1-mini" |
Language model used for module interpretation |
NCBI_api_key |
"" |
NCBI Entrez API key, used when pubmed = TRUE |
llm_api_key |
"" |
API key for the selected LLM provider |
base_url |
NULL |
Custom API base URL; required when provider = "local" |
synthesize |
FALSE |
Advanced mode only: run the cross-module synthesis agent after annotation |
synthesis_model |
llm_model |
Model used by the synthesis agent |
debate_html |
FALSE |
Advanced mode only: export one chat-style debate trace per module |
html_dir |
getwd() |
Directory for the advanced-mode HTML outputs |
If ref_data = NULL, FeatureMAP automatically loads the cached HMDB and KEGG functional and pathway references.
How to obtain an LLM API key
- OpenAI: https://platform.openai.com/
- Gemini (Google AI Studio): https://ai.google.dev/aistudio
- SiliconFlow: https://siliconflow.cn/
provider = "local" targets any OpenAI-compatible endpoint (for example a self-hosted vLLM server) and needs no key, only a base_url.
How to obtain a PubMed / NCBI API key
If you enable PubMed retrieval, apply for an NCBI API key for higher request limits: https://account.ncbi.nlm.nih.gov/settings/
Which significance column should you use? p = "fdr" with pcutoff = 0.05 is the right default for a real study. On the demo dataset, however, no module reaches fdr < 0.05 and only one module passes the raw permutation p-value (see Module Identification). The examples below therefore use p = "p", so that the step actually has a module to annotate.
Always inspect res$module_score before this step, and state in your write-up which column you filtered on.
6.3 Basic Usage
res_fast <- annotate_module(
res = res,
p = "p",
pcutoff = 0.05,
mode = "fast",
species = "human",
biospecimen = "plasma",
phenotype = "your disease phenotype",
pubmed = FALSE,
provider = "openai",
llm_model = "gpt-4.1-mini",
llm_api_key = Sys.getenv("OPENAI_API_KEY")
)res_adv <- annotate_module(
res = res,
p = "p",
pcutoff = 0.05,
mode = "advanced",
species = "human",
biospecimen = "plasma",
phenotype = "your disease phenotype",
pubmed = TRUE,
years = 5,
debate_html = TRUE,
synthesize = TRUE,
synthesis_model = "gpt-4.1",
html_dir = "featuremap_html",
provider = "openai",
llm_model = "gpt-4.1",
NCBI_api_key = Sys.getenv("NCBI_API_KEY"),
llm_api_key = Sys.getenv("OPENAI_API_KEY")
)res_fast <- annotate_module(
res = res,
p = "p",
pcutoff = 0.05,
mode = "fast",
species = "human",
biospecimen = "plasma",
phenotype = "your disease phenotype",
pubmed = TRUE,
provider = "local",
llm_model = "your-local-model-name",
base_url = "http://127.0.0.1:8000",
NCBI_api_key = Sys.getenv("NCBI_API_KEY")
)- Provide
species,biospecimenandphenotypewhenever possible: they are what turns a biochemical description into a study-specific interpretation. - Keep API keys in environment variables rather than hard-coding them.
- Assign the result to a new object (
res_fast,res_adv) if you want to compare both modes on the sameres.
6.4 Result Interpretation
After successful annotation:
res_fast$stage
# [1] "module_annotated"
names(res_fast)
# Example output; exact components can vary by mode and package version.
# [1] "module_function" "module_evidence" "study_context"
# [4] "module_score" "module_table" "pcsf_subnet"
# [7] "anno_feature_table2" "anno_feature_table1" "feature_table"
# [10] "stage" "object_type"6.4.1 Module interpretation table
module_function contains the final module-level interpretation. In fast mode it has one row per annotated module; in advanced mode one row per accepted hypothesis (or a single summary row when all hypotheses of a module were rejected).
res_fast$module_function$title
# Example output; LLM-generated text depends on the model and prompt.
# [1] "Purine nucleotide turnover and energy metabolism"
res_fast$module_function %>%
dplyr::select(module_id, hypothesis_type, title, confidence_label) %>%
as.data.frame()
# module_id hypothesis_type title confidence_label
# 1 M4 Host-Endogenous Purine nucleotide turnover and energy metabolism medium
res_fast$module_function$functional_summary[1]
# [1] "This module is enriched for purine-related metabolites and connected cofactors, suggesting altered nucleotide turnover and cellular energy metabolism."
res_fast$module_function$phenotype_relationship[1]
# [1] "In the provided plasma phenotype context, the module may reflect changes in systemic purine metabolism; interpret this together with the module statistics and literature evidence."| Column | Description |
|---|---|
module_id |
Module ID from Step 4 ("M1", "M2", …) |
annotation_mode |
"fast" or "advanced" |
hypothesis_id, verdict_status |
Advanced mode only: hypothesis ID ("H1", …) and accepted / rejected |
hypothesis_type |
Host-Endogenous, Microbiome-Derived, Diet-Derived, Xenobiotic / Exogenous or Host–Microbiome |
title |
Concise functional label for the module |
functional_summary |
Paragraph-level biochemical interpretation, written without the phenotype |
anchor_metabolites, anchor_metabolite_ids |
Metabolites the interpretation is anchored on |
top_metabolites, top_metabolite_ids |
Leading metabolites of the module |
class_names |
Dominant compound classes |
phenotype_relationship |
How the module relates to the study phenotype |
confidence_label |
high, medium or low |
rationale |
Why the inferred function is plausible in the study context |
global_direction |
Overall direction of change across the module |
origin_endogenous_frac, origin_exogenous_frac |
Endogenous / exogenous origin profile |
top_kegg_pathways, top_hmdb_pathways |
Best-covered pathways |
pubmed_pmids |
PMIDs of the retained articles |
A common reporting pattern is to join the interpretation with the module statistics:
library(dplyr)
res_fast$module_function %>%
as.data.frame() %>%
left_join(res_fast$module_score, by = c("module_id" = "Module")) -> module_report
write.csv(module_report, "module_function_report.csv", row.names = FALSE)6.4.2 Supporting evidence
All evidence used for the annotation is kept in res$module_evidence:
names(res_fast$module_evidence)
# [1] "class_coverage" "pathway_coverage" "pubmed_evidence" "top_metabolites"
# "anchor_metabolites" "debate_trace" "debate_html_payload"
head(res_fast$module_evidence$class_coverage)
head(res_fast$module_evidence$pathway_coverage$kegg)
head(res_fast$module_evidence$pathway_coverage$hmdb)
head(res_fast$module_evidence$top_metabolites)
head(res_fast$module_evidence$pubmed_evidence)
# Example output is a set of evidence tables, one per evidence type.
# Inspect the objects separately because the tables have different columns.
# PubMed evidence is empty when pubmed = FALSE.class_coverage– compound class composition per modulepathway_coverage$hmdb/pathway_coverage$kegg– pathway ID, name, coverage and number of hits per moduletop_metabolites/anchor_metabolites– the metabolites shown to the model, with their rankspubmed_evidence– retained PMIDs, the evidence JSON and the phenotype-literature support per module (and per hypothesis in advanced mode)debate_trace– advanced mode only: raw responses of every agent (raw_response_p0,raw_response_s1,raw_response_arbiter, …), the debate digest, and the counts of proposed, surviving, accepted and rejected hypothesesdebate_html_payload– advanced mode only: the data needed to re-export the debate pages
6.4.3 Advanced-mode verdicts
res_adv$module_function %>%
dplyr::count(module_id, verdict_status)
# Example output; advanced-mode verdicts are model-dependent.
# # A tibble: 2 x 3
# module_id verdict_status n
# <chr> <chr> <int>
# 1 M4 accepted 1
# 2 M4 rejected 1
res_adv$module_evidence$debate_trace %>%
dplyr::select(module_id, n_proposed, n_survived_debate, n_accepted, n_rejected, residual_ambiguity)
# # A tibble: 1 x 6
# module_id n_proposed n_survived_debate n_accepted n_rejected residual_ambiguity
# <chr> <int> <int> <int> <int> <chr>
# 1 M4 2 2 1 1 "One alternative interpretation remains plausible."6.5 Interactive HTML Reports
6.5.1 Debate traces
With debate_html = TRUE, advanced mode writes one chat-style page per module to html_dir, named debate_<module_id>.html. You can also re-export them later from a saved result, without calling the LLM again:
# all modules
export_debate_html(res = res_adv, html_dir = "featuremap_html")
# a single module
export_debate_html(res = res_adv, module_id = "M1", html_dir = "featuremap_html")export_debate_html() is the recommended interface for saved annotation results. generate_debate_html() is a lower-level constructor that requires the complete debate payload for one module and is normally only needed in custom workflows.
Each page shows the Proposer, Skeptic and Arbiter turns in order, so you can follow exactly why a hypothesis was kept, revised or rejected.
6.5.2 Synthesis network
When synthesize = TRUE, an interactive network page synthesis_network.html is written to html_dir automatically. To re-export it from a saved result:
export_synthesis_network_html(
res = res_adv,
html_dir = "featuremap_html",
output_file = "synthesis_network.html"
)generate_synthesis_network_html() does the same from a bare res$module_synthesis list, which is useful when you keep only the synthesis object:
generate_synthesis_network_html(
synthesis = res_adv$module_synthesis,
study_context = res_adv$study_context,
output_dir = "featuremap_html",
output_file = "synthesis_network.html"
)6.6 Cross-Module Synthesis
synthesize_modules() takes the accepted hypotheses of all annotated modules and integrates them into one picture of the study. It is run automatically by annotate_module(mode = "advanced", synthesize = TRUE), and can also be called on its own, for example to re-run the synthesis with a stronger model:
res_adv <- synthesize_modules(
res = res_adv,
study_context = list(
species = "human",
biospecimen = "plasma",
phenotype = "your disease phenotype"
),
provider = "openai",
llm_model = "gpt-4.1",
llm_api_key = Sys.getenv("OPENAI_API_KEY"),
max_tokens = 4000,
temperature = 0
)synthesize_modules() requires advanced mode output. Fast mode produces one flat annotation per module and no hypothesis-level verdicts, so it cannot be synthesized this way. If study_context is NULL, it is taken from res$study_context.
If only one module has accepted hypotheses, the LLM call is skipped and res$module_synthesis is returned with the deterministic parts only.
The demo dataset cannot illustrate this step. Cross-module synthesis only becomes meaningful when several modules carry accepted hypotheses. The demo data has a single significant module, so synthesize_modules() skips the LLM call and returns the deterministic parts only.
The outputs shown below are therefore taken from a larger real dataset with many annotated modules, and are reproduced here only to show the structure of res$module_synthesis. Do not expect to reproduce these values with the demo data.
The synthesis combines two very different kinds of links:
- Biology links are proposed by the LLM from hypothesis titles and summaries only, and are deliberately sparse.
- Biochemical links are computed in R from direct MRN edges between top metabolites and from pathway ID overlap, so they are fully deterministic.
Results are stored in res$module_synthesis:
names(res_adv$module_synthesis)
# [1] "nodes" "clusters" "biology_links" "biochemical_links"
# "relationships_sparse" "relationships" "module_relationships"
# "summary" "raw_response"
res_adv$module_synthesis$clusters
# Example output; synthesis is generated from accepted advanced-mode hypotheses.
# # A tibble: 1 x 3
# cluster_id cluster_theme node_ids
# <chr> <chr> <chr>
# 1 C1 Purine and energy metabolism "M4::H1; M12::H1"
res_adv$module_synthesis$module_relationships
# # A tibble: 1 x 11
# module_id_1 module_id_2 has_biochemical_link has_biology_link relation_type ...
# <chr> <chr> <lgl> <lgl> <chr> ...
# 1 M4 M12 TRUE FALSE biochemical ...
cat(res_adv$module_synthesis$summary)
# The accepted module hypotheses form a study-level pattern centered on purine
# metabolism and related energy-transfer chemistry. The biochemical links should
# be interpreted together with the original module scores and phenotype context.nodes– one row per accepted hypothesis, withnode_id("<module_id>::<hypothesis_id>") and its assigned clusterclusters– thematic clusters (cluster_id,cluster_theme, and semicolon-separated membernode_ids)biology_links/relationships_sparse– LLM-proposed cross-module relationships with rationalesbiochemical_links– MRN-edge and pathway-overlap evidence between hypothesesrelationships– the merged relationship table used to draw the networkmodule_relationships– module-pair summary with biochemical/biology flags, relation evidence, andrelation_typebeingbiochemical,biology,bothornonesummary– the narrative integrating clusters, cross-module links and the phenotyperaw_response– the original LLM response, kept for transparency
6.7 Practical Checks Before Running
head(res$module_score)
sum(res$module_score$fdr < 0.05, na.rm = TRUE)
sum(res$module_score$p < 0.05, na.rm = TRUE)If no module passes your threshold, annotate_module() returns an empty module_function and warns you. Either relax the filter (p = "p" instead of "fdr") or revisit Step 4 before trying module annotation.
6.8 Common Issues
No module annotated. Nothing passed
p/pcutoff. Switch top = "p", raisepcutoff, or revisit the module identification settings.PubMed retrieval is slow. Start with a shorter
yearswindow, fewer significant modules, orpubmed = FALSEwhile you validate the workflow.Advanced mode is expensive. Each module costs several LLM calls. Validate the pipeline in fast mode first, then re-run the modules you care about in advanced mode.
synthesize = TRUEwas ignored. Synthesis only runs in advanced mode; in fast mode a warning is emitted and the step is skipped.Provider/model mismatch. Make sure
llm_modelexists for the selectedprovider, and remember thatprovider = "local"requires a non-emptybase_url.
6.9 Next Step
Module annotation is the last step of the R-package workflow. res$module_function, res$module_evidence and res$module_synthesis, together with the exported HTML pages, contain everything you need for downstream reporting.
If you want to continue with the interactive interface, move to Shiny App Installation to start the Shiny tutorial.