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().

Important

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:

  1. Metabolite annotation: Metabolites of each significant module are annotated with class and pathway metadata from both HMDB and KEGG references.
  2. 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.
  3. 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:

  1. LLM call 1 – from metabolite evidence only (no phenotype), the model returns hypothesis_type, title and functional_summary.
  2. Optional PubMed retrieval – the module title and each anchor metabolite are searched separately; the retained articles are deduplicated by PMID.
  3. LLM call 2 – with the study context and the literature, the model returns phenotype_relationship, confidence_label and rationale.

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:

  1. P0 Proposer – generates 1–3 candidate biochemical hypotheses from the metabolite evidence only.
  2. S1 Skeptic – challenges each hypothesis on biochemical, origin, species and biospecimen grounds, without seeing the phenotype or the literature.
  3. P1 Proposer revision – keeps, modifies or drops each hypothesis.
  4. S2 / P2 – a second debate round runs only if revisions were made or major challenges remained unanswered.
  5. Optional PubMed – anchor-metabolite and per-hypothesis title searches, retained for the arbiter only.
  6. A0 Arbiter – delivers a final accepted / rejected verdict per hypothesis, with a confidence score and a residual-ambiguity note.
  7. Optional synthesis – with synthesize = TRUE, accepted hypotheses from all modules are integrated across modules (see Section 6.6).
Tip

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
Note

If ref_data = NULL, FeatureMAP automatically loads the cached HMDB and KEGG functional and pathway references.

Tip

How to obtain an LLM API key

provider = "local" targets any OpenAI-compatible endpoint (for example a self-hosted vLLM server) and needs no key, only a base_url.

Tip

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/

Important

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",     # 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")
)
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")
)
Note
  • Provide species, biospecimen and phenotype whenever 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 same res.

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 module
  • pathway_coverage$hmdb / pathway_coverage$kegg – pathway ID, name, coverage and number of hits per module
  • top_metabolites / anchor_metabolites – the metabolites shown to the model, with their ranks
  • pubmed_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 hypotheses
  • debate_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
)
Important

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.

Note

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, with node_id ("<module_id>::<hypothesis_id>") and its assigned cluster
  • clusters – thematic clusters (cluster_id, cluster_theme, and semicolon-separated member node_ids)
  • biology_links / relationships_sparse – LLM-proposed cross-module relationships with rationales
  • biochemical_links – MRN-edge and pathway-overlap evidence between hypotheses
  • relationships – the merged relationship table used to draw the network
  • module_relationships – module-pair summary with biochemical/biology flags, relation evidence, and relation_type being biochemical, biology, both or none
  • summary – the narrative integrating clusters, cross-module links and the phenotype
  • raw_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 to p = "p", raise pcutoff, or revisit the module identification settings.

  • PubMed retrieval is slow. Start with a shorter years window, fewer significant modules, or pubmed = FALSE while 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 = TRUE was 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_model exists for the selected provider, and remember that provider = "local" requires a non-empty base_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.