From a8b94135e872d0290ca4a94d0b7ceb0079fd0017 Mon Sep 17 00:00:00 2001 From: Tony Wu Date: Tue, 6 Oct 2026 18:13:50 -0400 Subject: [PATCH 1/2] Added get_curations() and filter_by_curation() * get_curations() counts the evidence curated as incorrect, one request per statement hash, at the new indra_backend(curation_url =) * filter_by_curation() subtracts it from evidence_count, drops edges left below min_evidence or with no evidence, and drops nodes left without edges * The backend comes from backend_database, as in get_evidence(); a get_curations() table with a non-character statement_id errors * getSubnetworkFromIndra(filter_by_curation = TRUE) now calls filter_by_curation() Co-Authored-By: Claude Opus 5.5 --- NAMESPACE | 3 + NEWS.md | 12 + R/AllClasses.R | 10 +- R/AllGenerics.R | 47 +++ R/backend-indra-db.R | 40 +++ R/backend-indra.R | 43 ++- R/backend-registry.R | 62 ++++ R/getSubnetworkFromIndra.R | 11 +- R/network-postprocess.R | 98 +++++++ R/utils_getSubnetworkFromIndra.R | 51 ---- man/NetworkBackend-class.Rd | 6 +- man/filter_by_curation.Rd | 62 ++++ man/getSubnetworkFromIndra.Rd | 4 +- man/get_curations.Rd | 58 ++++ man/indra_backend.Rd | 17 +- tests/testthat/test-curation.R | 283 +++++++++++++++++++ tests/testthat/test-getSubnetworkFromIndra.R | 21 +- 17 files changed, 758 insertions(+), 70 deletions(-) create mode 100644 R/backend-indra-db.R create mode 100644 R/network-postprocess.R create mode 100644 man/filter_by_curation.Rd create mode 100644 man/get_curations.Rd create mode 100644 tests/testthat/test-curation.R diff --git a/NAMESPACE b/NAMESPACE index e99e5b1..6c5b6e6 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -13,7 +13,9 @@ export(decomposeSubnetworkIntoHierarchicalTopics) export(deleteEdgeFromNetwork) export(exportNetworkToHTML) export(filterSubnetworkByContext) +export(filter_by_curation) export(getSubnetworkFromIndra) +export(get_curations) export(get_entity_properties) export(get_evidence) export(get_network) @@ -30,6 +32,7 @@ exportClasses(NetworkQuery) exportClasses(SubnetworkQuery) exportMethods(backend_capabilities) exportMethods(convert_ids) +exportMethods(get_curations) exportMethods(get_entity_properties) exportMethods(get_evidence) exportMethods(get_network) diff --git a/NEWS.md b/NEWS.md index 0f6a787..632d094 100644 --- a/NEWS.md +++ b/NEWS.md @@ -31,6 +31,13 @@ metabolites: glossary. `subnetwork_query()` is the first; more are planned. * `get_evidence(backend, edges)` returns the evidence sentences and PubMed IDs behind each edge, looked up by `statement_id`. + * `get_curations(backend, edges)` counts, for each statement, the + evidence curated as incorrect, and `filter_by_curation(network, + min_evidence = 1)` subtracts it from `evidence_count`, drops the edges + left below `min_evidence` (and edges left with no evidence), and drops + the nodes left without edges. Like `get_evidence()`, it uses the + backend named in each edge's `backend_database` unless `backend` is + given. `indra_backend(curation_url = )` sets the INDRA database URL. * The S4 classes `NetworkBackend`, `IndraBackend`, `NetworkQuery`, and `SubnetworkQuery` are exported, so other packages can add backends. * New function `validate_network()` checks a `list(nodes, edges)` network @@ -166,6 +173,11 @@ Like `get_network()`, it now prints the question it asks as a message. * The context and topic functions get edge evidence through `get_evidence()`. The INDRA evidence lookup now uses the backend's `cogex_url` in place of a hard-coded URL. Its output is unchanged. +* `getSubnetworkFromIndra(filter_by_curation = TRUE)` runs +`filter_by_curation()`. It looks up each statement hash once, where it used +to look up every edge, uses the backend's `curation_url`, prints how many +edges it drops, and also drops edges left with no evidence when +`evidence_count_cutoff` is below 1. # MSstatsBioNet 0.99.0 diff --git a/R/AllClasses.R b/R/AllClasses.R index ee73033..cc1ae8b 100644 --- a/R/AllClasses.R +++ b/R/AllClasses.R @@ -8,10 +8,13 @@ #' extending \code{NetworkBackend} and writing methods for the generics. #' #' \code{IndraBackend} queries INDRA CoGEx for networks and grounds names -#' with Gilda, INDRA's grounding service. +#' with Gilda, INDRA's grounding service. Curations come from the INDRA +#' database. #' #' @slot cogex_url base URL of INDRA CoGEx #' @slot grounding_url base URL of Gilda +#' @slot curation_url base URL of the INDRA database, which holds the +#' curations #' #' @seealso \code{\link{indra_backend}()}, \code{\link{backend_capabilities}()} #' @name NetworkBackend-class @@ -24,10 +27,11 @@ setClass("NetworkBackend", representation("VIRTUAL")) setClass("IndraBackend", contains = "NetworkBackend", representation(cogex_url = "character", - grounding_url = "character")) + grounding_url = "character", + curation_url = "character")) setValidity("IndraBackend", function(object) { - for (slot_name in c("cogex_url", "grounding_url")) { + for (slot_name in c("cogex_url", "grounding_url", "curation_url")) { url <- methods::slot(object, slot_name) if (length(url) != 1 || is.na(url) || !nzchar(url)) { return(paste(slot_name, "must be a single non-empty string")) diff --git a/R/AllGenerics.R b/R/AllGenerics.R index ab5e914..de8d8b6 100644 --- a/R/AllGenerics.R +++ b/R/AllGenerics.R @@ -228,6 +228,53 @@ setMethod("get_evidence", "NetworkBackend", call. = FALSE) }) +#' Get the curations of edges from a backend +#' +#' Curators mark single pieces of evidence for a statement as correct or +#' incorrect. \code{get_curations()} counts, for each statement, the +#' evidence curated as incorrect. INDRA curations come from the INDRA +#' database, with one request per statement. Most users call +#' \code{\link{filter_by_curation}()}, which subtracts these counts from +#' \code{evidence_count}. +#' +#' @inheritParams get_evidence +#' @param edges the \code{edges} of a network from +#' \code{\link{get_network}()}. Needs the column \code{statement_id}. +#' @return data.frame with one row per unique \code{statement_id} and the +#' columns \code{statement_id} (character) and \code{incorrect_count} +#' (integer). A failed request warns and counts as 0. A method for another +#' backend may leave out statements with no curations, which +#' \code{\link{filter_by_curation}()} counts as 0, but must return +#' \code{statement_id} as character: \code{filter_by_curation()} errors +#' otherwise, since a numeric hash can lose precision and match no edge. +#' @seealso \code{\link{filter_by_curation}()} +#' @export +#' @examples +#' \donttest{ +#' input <- data.table::fread(system.file( +#' "extdata/groupComparisonModel.csv", +#' package = "MSstatsBioNet" +#' )) +#' indra <- indra_backend() +#' entities <- prepare_entities(input, entity_type = "protein", +#' id_type = "uniprot") +#' entities <- convert_ids(indra, entities) +#' entities <- select_entities(entities, pvalue_cutoff = 0.05) +#' network <- get_network(indra, entities, interaction_types = "Complex") +#' get_curations(indra, head(network$edges, 2)) +#' } +setGeneric("get_curations", + function(backend, edges, ...) standardGeneric("get_curations"), + signature = "backend") + +#' @rdname get_curations +#' @export +setMethod("get_curations", "NetworkBackend", + function(backend, edges, ...) { + stop(class(backend), " does not support get_curations().", + call. = FALSE) + }) + #' What a backend supports #' #' Lists the queries, identifier conversions, entity properties, and diff --git a/R/backend-indra-db.R b/R/backend-indra-db.R new file mode 100644 index 0000000..976375c --- /dev/null +++ b/R/backend-indra-db.R @@ -0,0 +1,40 @@ +# HTTP calls to the INDRA database (db.indra.bio), a different service from +# CoGEx. + +#' Count the evidence of a statement curated as incorrect +#' +#' Curations tagged anything other than \code{"correct"} count as +#' incorrect, once per evidence (\code{source_hash}). A failed request +#' warns and counts as 0. +#' +#' @param statement_id INDRA statement hash +#' @param curation_url base URL of the INDRA database +#' @return number of evidences curated as incorrect +#' @keywords internal +#' @noRd +#' @importFrom httr GET status_code content +#' @importFrom jsonlite fromJSON +.get_incorrect_curation_count <- function(statement_id, + curation_url = INDRA_DB_URL) { + statement_id <- as.character(statement_id) + url <- file.path(curation_url, "curation/list", statement_id) + + tryCatch({ + response <- GET(url) + if (status_code(response) == 200) { + curations <- fromJSON(content(response, "text", encoding = "UTF-8")) + if (length(curations) == 0) { + return(0) + } + incorrect_curations <- curations[curations$tag != "correct", ] + length(unique(incorrect_curations$source_hash)) + } else { + warning(paste("API request failed for hash", statement_id, + "with status code", status_code(response))) + 0 + } + }, error = function(e) { + warning(paste("Error processing hash", statement_id, ":", e$message)) + 0 + }) +} diff --git a/R/backend-indra.R b/R/backend-indra.R index 7881b57..3da26e1 100644 --- a/R/backend-indra.R +++ b/R/backend-indra.R @@ -8,12 +8,18 @@ INDRA_API_URL <- "https://discovery.indra.bio" #' @noRd GILDA_API_URL <- "https://grounding.indra.bio" +#' Base URL of the INDRA database, which holds the curations +#' @keywords internal +#' @noRd +INDRA_DB_URL <- "https://db.indra.bio" + #' Create an INDRA backend #' #' INDRA is a knowledge graph of mechanisms (activations, phosphorylations, #' complexes, ...) assembled from the literature and curated databases. The #' backend queries INDRA CoGEx for networks and grounds gene symbols and -#' chemical names with Gilda, INDRA's grounding service. +#' chemical names with Gilda, INDRA's grounding service. Curations, which +#' mark evidence as correct or incorrect, come from the INDRA database. #' \code{backend_capabilities(indra_backend())} lists what it supports. #' #' This function includes third-party software components that are licensed @@ -23,9 +29,12 @@ GILDA_API_URL <- "https://grounding.indra.bio" #' #' @param cogex_url base URL of INDRA CoGEx #' @param grounding_url base URL of Gilda +#' @param curation_url base URL of the INDRA database, which holds the +#' curations #' @return an \code{IndraBackend} object, to pass to -#' \code{\link{convert_ids}()}, \code{\link{get_entity_properties}()}, and -#' \code{\link{get_network}()} +#' \code{\link{convert_ids}()}, \code{\link{get_entity_properties}()}, +#' \code{\link{get_network}()}, \code{\link{get_evidence}()}, and +#' \code{\link{get_curations}()} #' @seealso \code{\link{NetworkBackend-class}} #' @importFrom methods new #' @export @@ -33,8 +42,10 @@ GILDA_API_URL <- "https://grounding.indra.bio" #' indra <- indra_backend() #' backend_capabilities(indra)$query_types indra_backend <- function(cogex_url = INDRA_API_URL, - grounding_url = GILDA_API_URL) { - new("IndraBackend", cogex_url = cogex_url, grounding_url = grounding_url) + grounding_url = GILDA_API_URL, + curation_url = INDRA_DB_URL) { + new("IndraBackend", cogex_url = cogex_url, grounding_url = grounding_url, + curation_url = curation_url) } # INDRA subnetwork query. Sends the groundings of the included_in_query rows @@ -108,6 +119,28 @@ setMethod("get_evidence", "IndraBackend", .build_indra_evidence_table(edges, evidence_by_statement) }) +# INDRA curations. Asks the INDRA database for the curations of each unique +# statement hash, one request per hash, and counts the evidence curated as +# anything other than correct. +#' @rdname get_curations +#' @export +setMethod("get_curations", "IndraBackend", + function(backend, edges, ...) { + if (!"statement_id" %in% names(edges)) { + stop("Missing required columns: statement_id") + } + statement_ids <- unique(as.character(edges$statement_id)) + incorrect_counts <- integer(length(statement_ids)) + for (i in seq_along(statement_ids)) { + if (i > 1) Sys.sleep(0.1) + incorrect_counts[i] <- as.integer(.get_incorrect_curation_count( + statement_ids[i], backend@curation_url)) + } + data.frame(statement_id = statement_ids, + incorrect_count = incorrect_counts, + stringsAsFactors = FALSE) + }) + #' Edge columns that get_evidence() copies to its output #' @keywords internal #' @noRd diff --git a/R/backend-registry.R b/R/backend-registry.R index 51f2f46..d68e853 100644 --- a/R/backend-registry.R +++ b/R/backend-registry.R @@ -85,3 +85,65 @@ BACKEND_CONSTRUCTORS <- list( } do.call(rbind, evidence) } + +#' Count the incorrect evidence of each edge, from the backend of each edge +#' +#' Calls \code{get_curations()} once per backend, see +#' \code{.split_edges_by_backend()}, and matches the counts back to the +#' edges within each backend, since two backends can share a +#' \code{statement_id}. Statements a backend returns no count for count 0, +#' after \code{.check_curation_table()} has ruled out a \code{statement_id} +#' that can't match, such as a number. +#' +#' @param edges edges data.frame +#' @param backend \code{NULL} or a \code{NetworkBackend} +#' @return integer vector, one count per row of \code{edges} +#' @keywords internal +#' @noRd +.count_incorrect_evidence <- function(edges, backend = NULL) { + incorrect_counts <- integer(nrow(edges)) + edges$.edge_row <- seq_len(nrow(edges)) + for (group in .split_edges_by_backend(edges, backend)) { + curations <- get_curations(group$backend, group$edges) + .check_curation_table(curations, group$backend) + counts <- curations$incorrect_count[match( + as.character(group$edges$statement_id), + as.character(curations$statement_id))] + counts[is.na(counts)] <- 0L + incorrect_counts[group$edges$.edge_row] <- as.integer(counts) + } + incorrect_counts +} + +#' Check the table a get_curations() method returned +#' +#' A numeric \code{statement_id} matches no edge (INDRA hashes above 2^53 +#' lose precision as numbers), so every edge would silently count 0 +#' incorrect evidence. Errors naming the backend. +#' +#' @param curations output of \code{get_curations()} +#' @param backend the backend that returned it +#' @return \code{NULL}, invisibly; errors otherwise +#' @keywords internal +#' @noRd +.check_curation_table <- function(curations, backend) { + problem <- if (!is.data.frame(curations) || + !all(c("statement_id", "incorrect_count") %in% + names(curations))) { + "a data.frame with columns statement_id and incorrect_count" + } else if (!is.character(curations$statement_id) || + anyNA(curations$statement_id)) { + "a character statement_id with no NA" + } else if (!is.numeric(curations$incorrect_count) || + anyNA(curations$incorrect_count) || + any(curations$incorrect_count < 0) || + any(curations$incorrect_count != + round(curations$incorrect_count))) { + "an incorrect_count of non-negative whole numbers" + } + if (!is.null(problem)) { + stop("get_curations() for ", class(backend), " must return ", + problem, ".", call. = FALSE) + } + invisible(NULL) +} diff --git a/R/getSubnetworkFromIndra.R b/R/getSubnetworkFromIndra.R index bb8a4f1..0fe9e69 100644 --- a/R/getSubnetworkFromIndra.R +++ b/R/getSubnetworkFromIndra.R @@ -38,7 +38,9 @@ #' network, regardless if those ids are in the input data. Should be formatted #' as "namespace:identifier", e.g. "HGNC:1234" or "CHEBI:4911". #' @param filter_by_curation logical, whether to filter out statements that -#' have been curated as incorrect in INDRA. Default is FALSE. +#' have been curated as incorrect in INDRA. Default is FALSE. Runs +#' \code{\link{filter_by_curation}()} with +#' \code{min_evidence = evidence_count_cutoff}. #' @param filter_by_ptm_site logical, whether to filter edges based on whether the #' site information from INDRA matches with the PTM site in the input. Default is FALSE. #' Only applicable for differential PTM abundance results. @@ -136,7 +138,12 @@ getSubnetworkFromIndra <- function(input, edges = edges) } subnetwork = .filterByPtmSite(subnetwork$nodes, subnetwork$edges, filter_by_ptm_site) - subnetwork = .filterByCuration(subnetwork$nodes, subnetwork$edges, evidence_count_cutoff, filter_by_curation) + if (filter_by_curation) { + # the call finds the exported function, not this logical argument + subnetwork <- filter_by_curation(subnetwork, + min_evidence = evidence_count_cutoff, + backend = indra_backend()) + } validate_network(subnetwork) warning( "NOTICE: This function includes third-party software components diff --git a/R/network-postprocess.R b/R/network-postprocess.R new file mode 100644 index 0000000..7800c5b --- /dev/null +++ b/R/network-postprocess.R @@ -0,0 +1,98 @@ +# Filters that run on a finished network, after get_network(). + +#' Remove evidence curated as incorrect +#' +#' Subtracts the evidence curated as incorrect, from +#' \code{\link{get_curations}()}, from each edge's \code{evidence_count}, +#' then drops the edges left below \code{min_evidence}, and the nodes those +#' edges leave without any edge. Edges left with no evidence are dropped +#' whatever \code{min_evidence} is. A message says how many edges were +#' dropped. +#' +#' Run it after any filter that pools edges, such as the PTM-site filter of +#' \code{\link{getSubnetworkFromIndra}()}: dropping edges first can change +#' which edges such a filter keeps. +#' +#' @param network list of \code{nodes} and \code{edges} from +#' \code{\link{get_network}()}. \code{edges} needs the columns +#' \code{source}, \code{target}, \code{statement_id}, and +#' \code{evidence_count}, and \code{backend_database} unless +#' \code{backend} is given. +#' @param min_evidence minimum evidence count per edge, after subtracting +#' the incorrect evidence +#' @param backend the backend to get the curations from, e.g. +#' \code{\link{indra_backend}()}. \code{NULL} (default) uses the default +#' backend named in each edge's \code{backend_database}. Pass a backend to +#' use one built with non-default settings, such as +#' \code{indra_backend(curation_url = )}. A backend without curations, such +#' as one with no \code{get_curations()} method, gives an error naming it. +#' @return \code{network}, with \code{edges$evidence_count} reduced and the +#' dropped edges and nodes removed. Other elements of \code{network} are +#' kept. +#' @seealso \code{\link{get_curations}()} +#' @export +#' @examples +#' \donttest{ +#' input <- data.table::fread(system.file( +#' "extdata/groupComparisonModel.csv", +#' package = "MSstatsBioNet" +#' )) +#' indra <- indra_backend() +#' entities <- prepare_entities(input, entity_type = "protein", +#' id_type = "uniprot") +#' entities <- convert_ids(indra, entities) +#' entities <- select_entities(entities, pvalue_cutoff = 0.05) +#' network <- get_network(indra, entities, interaction_types = "Complex") +#' network <- filter_by_curation(network, min_evidence = 2) +#' head(network$edges) +#' } +filter_by_curation <- function(network, min_evidence = 1, backend = NULL) { + .check_filter_by_curation_input(network, min_evidence) + .check_backend_argument(backend) + edges <- network$edges + incorrect_counts <- .count_incorrect_evidence(edges, backend) + edges$evidence_count <- as.integer(edges$evidence_count - incorrect_counts) + keep <- edges$evidence_count >= min_evidence & edges$evidence_count >= 1 + if (any(!keep)) { + message("Dropping ", sum(!keep), " edge(s) with fewer than ", + max(min_evidence, 1), " evidence after removing the ", + "evidence curated as incorrect.") + } + nodes <- network$nodes + endpoints_before <- c(edges$source, edges$target) + edges <- edges[keep, , drop = FALSE] + endpoints_after <- c(edges$source, edges$target) + left_without_edges <- nodes$id %in% endpoints_before & + !nodes$id %in% endpoints_after + network$nodes <- nodes[!left_without_edges, , drop = FALSE] + network$edges <- edges + network +} + +#' Check the input of filter_by_curation() +#' @param network the network argument +#' @param min_evidence the min_evidence argument +#' @return \code{NULL}, invisibly; errors otherwise +#' @keywords internal +#' @noRd +.check_filter_by_curation_input <- function(network, min_evidence) { + if (!is.list(network) || !is.data.frame(network$nodes) || + !is.data.frame(network$edges)) { + stop("`network` must be a list with `nodes` and `edges` ", + "data.frames, e.g. from get_network().", call. = FALSE) + } + missing_cols <- setdiff(c("source", "target", "statement_id", + "evidence_count"), names(network$edges)) + if (length(missing_cols) > 0) { + stop("`network$edges` is missing the column(s): ", + paste(missing_cols, collapse = ", "), call. = FALSE) + } + if (!"id" %in% names(network$nodes)) { + stop("`network$nodes` is missing the column: id", call. = FALSE) + } + if (!is.numeric(min_evidence) || length(min_evidence) != 1 || + !is.finite(min_evidence)) { + stop("`min_evidence` must be a single number.", call. = FALSE) + } + invisible(NULL) +} diff --git a/R/utils_getSubnetworkFromIndra.R b/R/utils_getSubnetworkFromIndra.R index c0c18f3..323512c 100644 --- a/R/utils_getSubnetworkFromIndra.R +++ b/R/utils_getSubnetworkFromIndra.R @@ -91,57 +91,6 @@ return(edges) } -#' @importFrom httr GET status_code content -#' @importFrom jsonlite fromJSON -.get_incorrect_curation_count <- function(stmt_hash) { - stmt_hash_char <- as.character(stmt_hash) - url <- paste0("https://db.indra.bio/curation/list/", stmt_hash_char) - - tryCatch({ - response <- GET(url) - if (status_code(response) == 200) { - curations <- fromJSON(content(response, "text", encoding = "UTF-8")) - if (length(curations) == 0) { - return(0) - } - incorrect_curations <- curations[curations$tag != "correct", ] - unique_incorrect <- length(unique(incorrect_curations$source_hash)) - - return(unique_incorrect) - } else { - warning(paste("API request failed for hash", stmt_hash_char, - "with status code", status_code(response))) - return(0) - } - }, error = function(e) { - warning(paste("Error processing hash", stmt_hash_char, ":", e$message)) - return(0) - }) -} - -#' Subtract evidence curated as incorrect in INDRA, then re-apply the cutoff -#' @param nodes nodes data frame -#' @param edges edges data frame -#' @param evidence_count_cutoff minimum evidence count per edge -#' @param filter_by_curation logical; if FALSE, nodes and edges are returned -#' unchanged -#' @return list of nodes and edges -#' @keywords internal -#' @noRd -.filterByCuration = function(nodes, edges, evidence_count_cutoff, filter_by_curation) { - if (filter_by_curation) { - incorrect_counts <- numeric(nrow(edges)) - for (i in seq_len(nrow(edges))) { - incorrect_counts[i] <- .get_incorrect_curation_count(edges$statement_id[i]) - Sys.sleep(0.1) - } - edges$evidence_count <- as.integer(edges$evidence_count - incorrect_counts) - edges <- edges[edges$evidence_count >= evidence_count_cutoff, ] - nodes <- nodes[nodes$id %in% c(edges$source, edges$target), ] - } - return(list(nodes = nodes, edges = edges)) -} - .filterByPtmSite = function(nodes, edges, filter_by_ptm_site) { if (filter_by_ptm_site && nrow(nodes[!is.na(nodes$site), ]) > 0) { ptm_overlap <- .ptmOverlap(edges, nodes) diff --git a/man/NetworkBackend-class.Rd b/man/NetworkBackend-class.Rd index cf12a56..5acd2c5 100644 --- a/man/NetworkBackend-class.Rd +++ b/man/NetworkBackend-class.Rd @@ -14,7 +14,8 @@ extending \code{NetworkBackend} and writing methods for the generics. } \details{ \code{IndraBackend} queries INDRA CoGEx for networks and grounds names -with Gilda, INDRA's grounding service. +with Gilda, INDRA's grounding service. Curations come from the INDRA +database. } \section{Slots}{ @@ -22,6 +23,9 @@ with Gilda, INDRA's grounding service. \item{\code{cogex_url}}{base URL of INDRA CoGEx} \item{\code{grounding_url}}{base URL of Gilda} + +\item{\code{curation_url}}{base URL of the INDRA database, which holds the +curations} }} \seealso{ diff --git a/man/filter_by_curation.Rd b/man/filter_by_curation.Rd new file mode 100644 index 0000000..80a122c --- /dev/null +++ b/man/filter_by_curation.Rd @@ -0,0 +1,62 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/network-postprocess.R +\name{filter_by_curation} +\alias{filter_by_curation} +\title{Remove evidence curated as incorrect} +\usage{ +filter_by_curation(network, min_evidence = 1, backend = NULL) +} +\arguments{ +\item{network}{list of \code{nodes} and \code{edges} from +\code{\link{get_network}()}. \code{edges} needs the columns +\code{source}, \code{target}, \code{statement_id}, and +\code{evidence_count}, and \code{backend_database} unless +\code{backend} is given.} + +\item{min_evidence}{minimum evidence count per edge, after subtracting +the incorrect evidence} + +\item{backend}{the backend to get the curations from, e.g. +\code{\link{indra_backend}()}. \code{NULL} (default) uses the default +backend named in each edge's \code{backend_database}. Pass a backend to +use one built with non-default settings, such as +\code{indra_backend(curation_url = )}. A backend without curations, such +as one with no \code{get_curations()} method, gives an error naming it.} +} +\value{ +\code{network}, with \code{edges$evidence_count} reduced and the +dropped edges and nodes removed. Other elements of \code{network} are +kept. +} +\description{ +Subtracts the evidence curated as incorrect, from +\code{\link{get_curations}()}, from each edge's \code{evidence_count}, +then drops the edges left below \code{min_evidence}, and the nodes those +edges leave without any edge. Edges left with no evidence are dropped +whatever \code{min_evidence} is. A message says how many edges were +dropped. +} +\details{ +Run it after any filter that pools edges, such as the PTM-site filter of +\code{\link{getSubnetworkFromIndra}()}: dropping edges first can change +which edges such a filter keeps. +} +\examples{ +\donttest{ +input <- data.table::fread(system.file( + "extdata/groupComparisonModel.csv", + package = "MSstatsBioNet" +)) +indra <- indra_backend() +entities <- prepare_entities(input, entity_type = "protein", + id_type = "uniprot") +entities <- convert_ids(indra, entities) +entities <- select_entities(entities, pvalue_cutoff = 0.05) +network <- get_network(indra, entities, interaction_types = "Complex") +network <- filter_by_curation(network, min_evidence = 2) +head(network$edges) +} +} +\seealso{ +\code{\link{get_curations}()} +} diff --git a/man/getSubnetworkFromIndra.Rd b/man/getSubnetworkFromIndra.Rd index fe877ac..e3aad01 100644 --- a/man/getSubnetworkFromIndra.Rd +++ b/man/getSubnetworkFromIndra.Rd @@ -67,7 +67,9 @@ network, regardless if those ids are in the input data. Should be formatted as "namespace:identifier", e.g. "HGNC:1234" or "CHEBI:4911".} \item{filter_by_curation}{logical, whether to filter out statements that -have been curated as incorrect in INDRA. Default is FALSE.} +have been curated as incorrect in INDRA. Default is FALSE. Runs +\code{\link{filter_by_curation}()} with +\code{min_evidence = evidence_count_cutoff}.} \item{filter_by_ptm_site}{logical, whether to filter edges based on whether the site information from INDRA matches with the PTM site in the input. Default is FALSE. diff --git a/man/get_curations.Rd b/man/get_curations.Rd new file mode 100644 index 0000000..2eb0430 --- /dev/null +++ b/man/get_curations.Rd @@ -0,0 +1,58 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/AllGenerics.R, R/backend-indra.R +\name{get_curations} +\alias{get_curations} +\alias{get_curations,NetworkBackend-method} +\alias{get_curations,IndraBackend-method} +\title{Get the curations of edges from a backend} +\usage{ +get_curations(backend, edges, ...) + +\S4method{get_curations}{NetworkBackend}(backend, edges, ...) + +\S4method{get_curations}{IndraBackend}(backend, edges, ...) +} +\arguments{ +\item{backend}{a \code{NetworkBackend}, e.g. from +\code{\link{indra_backend}()}} + +\item{edges}{the \code{edges} of a network from +\code{\link{get_network}()}. Needs the column \code{statement_id}.} + +\item{...}{passed to methods} +} +\value{ +data.frame with one row per unique \code{statement_id} and the +columns \code{statement_id} (character) and \code{incorrect_count} +(integer). A failed request warns and counts as 0. A method for another +backend may leave out statements with no curations, which +\code{\link{filter_by_curation}()} counts as 0, but must return +\code{statement_id} as character: \code{filter_by_curation()} errors +otherwise, since a numeric hash can lose precision and match no edge. +} +\description{ +Curators mark single pieces of evidence for a statement as correct or +incorrect. \code{get_curations()} counts, for each statement, the +evidence curated as incorrect. INDRA curations come from the INDRA +database, with one request per statement. Most users call +\code{\link{filter_by_curation}()}, which subtracts these counts from +\code{evidence_count}. +} +\examples{ +\donttest{ +input <- data.table::fread(system.file( + "extdata/groupComparisonModel.csv", + package = "MSstatsBioNet" +)) +indra <- indra_backend() +entities <- prepare_entities(input, entity_type = "protein", + id_type = "uniprot") +entities <- convert_ids(indra, entities) +entities <- select_entities(entities, pvalue_cutoff = 0.05) +network <- get_network(indra, entities, interaction_types = "Complex") +get_curations(indra, head(network$edges, 2)) +} +} +\seealso{ +\code{\link{filter_by_curation}()} +} diff --git a/man/indra_backend.Rd b/man/indra_backend.Rd index a849ca1..4a2bb29 100644 --- a/man/indra_backend.Rd +++ b/man/indra_backend.Rd @@ -4,23 +4,32 @@ \alias{indra_backend} \title{Create an INDRA backend} \usage{ -indra_backend(cogex_url = INDRA_API_URL, grounding_url = GILDA_API_URL) +indra_backend( + cogex_url = INDRA_API_URL, + grounding_url = GILDA_API_URL, + curation_url = INDRA_DB_URL +) } \arguments{ \item{cogex_url}{base URL of INDRA CoGEx} \item{grounding_url}{base URL of Gilda} + +\item{curation_url}{base URL of the INDRA database, which holds the +curations} } \value{ an \code{IndraBackend} object, to pass to -\code{\link{convert_ids}()}, \code{\link{get_entity_properties}()}, and -\code{\link{get_network}()} +\code{\link{convert_ids}()}, \code{\link{get_entity_properties}()}, +\code{\link{get_network}()}, \code{\link{get_evidence}()}, and +\code{\link{get_curations}()} } \description{ INDRA is a knowledge graph of mechanisms (activations, phosphorylations, complexes, ...) assembled from the literature and curated databases. The backend queries INDRA CoGEx for networks and grounds gene symbols and -chemical names with Gilda, INDRA's grounding service. +chemical names with Gilda, INDRA's grounding service. Curations, which +mark evidence as correct or incorrect, come from the INDRA database. \code{backend_capabilities(indra_backend())} lists what it supports. } \details{ diff --git a/tests/testthat/test-curation.R b/tests/testthat/test-curation.R new file mode 100644 index 0000000..d9470fc --- /dev/null +++ b/tests/testthat/test-curation.R @@ -0,0 +1,283 @@ +# ----- get_curations() and filter_by_curation() (Phase 4c) ----- + +.make_curation_network <- function() { + list( + nodes = data.frame(id = c("A", "B", "C", "D", "E"), + stringsAsFactors = FALSE), + edges = data.frame( + source = c("A", "B", "A", "C"), + target = c("B", "C", "C", "D"), + interaction = c("Activation", "Inhibition", "Complex", + "Activation"), + statement_id = c("111", "222", "333", "111"), + evidence_count = c(5L, 2L, 1L, 3L), + backend_database = "INDRA", + stringsAsFactors = FALSE + ), + regulators = data.frame(id = "A") + ) +} + +# A second backend for the registry tests: every statement has `incorrect` +# incorrect evidence. +setClass("TestCurationBackend", contains = "NetworkBackend", + representation(incorrect = "integer")) +setMethod("get_curations", "TestCurationBackend", function(backend, edges, ...) { + data.frame(statement_id = unique(edges$statement_id), + incorrect_count = backend@incorrect, + stringsAsFactors = FALSE) +}) + +test_that("indra_backend() defaults to the public INDRA database for curations", { + expect_equal(indra_backend()@curation_url, "https://db.indra.bio") +}) + +test_that("indra_backend() rejects an invalid curation_url", { + expect_error(indra_backend(curation_url = character(0)), "curation_url") + expect_error(indra_backend(curation_url = ""), "curation_url") + expect_error(indra_backend(curation_url = NA_character_), "curation_url") +}) + +describe("get_curations() for INDRA", { + + test_that("asks for each statement hash once, at the backend's curation_url", { + requested <- list() + local_mocked_bindings( + .get_incorrect_curation_count = function(statement_id, curation_url) { + requested[[length(requested) + 1]] <<- c(statement_id, curation_url) + c("111" = 2, "222" = 0, "333" = 1)[[statement_id]] + } + ) + backend <- indra_backend(curation_url = "https://curation.example.org") + result <- get_curations(backend, .make_curation_network()$edges) + expect_equal(requested, list( + c("111", "https://curation.example.org"), + c("222", "https://curation.example.org"), + c("333", "https://curation.example.org"))) + expect_equal(result, data.frame(statement_id = c("111", "222", "333"), + incorrect_count = c(2L, 0L, 1L), + stringsAsFactors = FALSE)) + }) + + test_that("returns an empty table without a request for zero edges", { + local_mocked_bindings( + .get_incorrect_curation_count = function(...) stop("no request expected") + ) + edges <- .make_curation_network()$edges[0, ] + result <- get_curations(indra_backend(), edges) + expect_equal(nrow(result), 0) + expect_type(result$statement_id, "character") + expect_type(result$incorrect_count, "integer") + }) + + test_that("errors when edges have no statement_id", { + edges <- .make_curation_network()$edges + edges$statement_id <- NULL + expect_error(get_curations(indra_backend(), edges), + "Missing required columns: statement_id") + }) +}) + +describe(".get_incorrect_curation_count()", { + + .mock_curation_response <- function(env, curations, status = 200) { + requested_url <- NULL + local_mocked_bindings( + GET = function(url, ...) { + requested_url <<- url + structure(list(), class = "response") + }, + status_code = function(x) status, + content = function(x, ...) "json", + fromJSON = function(...) curations, + .env = env + ) + function() requested_url + } + + test_that("counts each evidence curated as anything but correct once", { + curations <- data.frame( + tag = c("correct", "wrong_relation", "grounding", "wrong_relation"), + source_hash = c(1, 2, 3, 2) + ) + get_url <- .mock_curation_response(environment(), curations) + count <- .get_incorrect_curation_count("-123", "https://curation.example.org") + expect_equal(count, 2) + expect_equal(get_url(), "https://curation.example.org/curation/list/-123") + }) + + test_that("counts 0 when a statement has no curations", { + .mock_curation_response(environment(), list()) + expect_equal(.get_incorrect_curation_count("111"), 0) + }) + + test_that("warns and counts 0 when the request fails", { + .mock_curation_response(environment(), list(), status = 500) + expect_warning(count <- .get_incorrect_curation_count("111"), + "status code 500") + expect_equal(count, 0) + }) +}) + +test_that("get_curations() errors for a backend without a method", { + where <- environment() + setClass("NoCurationBackend", contains = "NetworkBackend", where = where) + on.exit(removeClass("NoCurationBackend", where = where), add = TRUE) + expect_error( + get_curations(new("NoCurationBackend"), .make_curation_network()$edges), + "NoCurationBackend does not support get_curations()", fixed = TRUE + ) +}) + +describe("filter_by_curation()", { + + .mock_indra_curations <- function(env, counts = c("111" = 3, "222" = 0, + "333" = 1)) { + local_mocked_bindings( + .get_incorrect_curation_count = function(statement_id, curation_url) { + counts[[statement_id]] + }, + .env = env + ) + } + + test_that("subtracts incorrect evidence and drops edges below min_evidence", { + .mock_indra_curations(environment()) + expect_message( + result <- filter_by_curation(.make_curation_network(), + min_evidence = 2), + "Dropping 2 edge(s) with fewer than 2 evidence", fixed = TRUE + ) + # 111: 5 - 3 = 2 and 3 - 3 = 0; 222: 2 - 0 = 2; 333: 1 - 1 = 0 + expect_equal(result$edges$statement_id, c("111", "222")) + expect_equal(result$edges$evidence_count, c(2L, 2L)) + expect_type(result$edges$evidence_count, "integer") + }) + + test_that("drops the nodes left without edges and keeps the rest", { + .mock_indra_curations(environment()) + result <- suppressMessages( + filter_by_curation(.make_curation_network(), min_evidence = 2)) + # D lost its only edge; E had no edge to begin with + expect_equal(result$nodes$id, c("A", "B", "C", "E")) + expect_equal(result$regulators, data.frame(id = "A")) + }) + + test_that("drops edges left with no evidence even when min_evidence is 0", { + .mock_indra_curations(environment()) + result <- suppressMessages( + filter_by_curation(.make_curation_network(), min_evidence = 0)) + expect_true(all(result$edges$evidence_count >= 1)) + expect_equal(result$edges$statement_id, c("111", "222")) + }) + + test_that("returns the network unchanged and silent when nothing is curated", { + .mock_indra_curations(environment(), + counts = c("111" = 0, "222" = 0, "333" = 0)) + network <- .make_curation_network() + expect_silent(result <- filter_by_curation(network)) + expect_equal(result, network) + }) + + test_that("uses the backend named in each edge's backend_database", { + network <- .make_curation_network() + network$edges$backend_database <- c("First", "Second", "First", "Second") + local_mocked_bindings(BACKEND_CONSTRUCTORS = list( + First = function() new("TestCurationBackend", incorrect = 0L), + Second = function() new("TestCurationBackend", incorrect = 1L) + )) + result <- suppressMessages(filter_by_curation(network)) + expect_equal(result$edges$statement_id, c("111", "222", "333", "111")) + expect_equal(result$edges$evidence_count, c(5L, 1L, 1L, 2L)) + }) + + test_that("an explicit backend takes every edge", { + network <- .make_curation_network() + network$edges$backend_database <- c("INDRA", "Unknown", NA, "INDRA") + used_url <- NULL + local_mocked_bindings( + .get_incorrect_curation_count = function(statement_id, curation_url) { + used_url <<- curation_url + 0 + } + ) + result <- filter_by_curation( + network, backend = indra_backend(curation_url = "https://db.example.org")) + expect_equal(used_url, "https://db.example.org") + expect_equal(nrow(result$edges), 4) + }) + + test_that("counts 0 for statements the backend returns no count for", { + local_mocked_bindings(get_curations = function(backend, edges, ...) { + data.frame(statement_id = "222", incorrect_count = 1L, + stringsAsFactors = FALSE) + }) + result <- suppressMessages(filter_by_curation(.make_curation_network())) + expect_equal(result$edges$evidence_count, c(5L, 1L, 1L, 3L)) + }) + + test_that("errors when a backend returns a table that can't match the edges", { + tables <- list( + "a data.frame with columns statement_id and incorrect_count" = + data.frame(statement_id = "111"), + # numeric hashes lose precision above 2^53 and match no edge + "a character statement_id with no NA" = + data.frame(statement_id = c(111, 222, 333), + incorrect_count = 1L), + "a character statement_id with no NA" = + data.frame(statement_id = NA_character_, incorrect_count = 1L), + "an incorrect_count of non-negative whole numbers" = + data.frame(statement_id = "111", incorrect_count = -1L), + "an incorrect_count of non-negative whole numbers" = + data.frame(statement_id = "111", incorrect_count = 0.5), + "an incorrect_count of non-negative whole numbers" = + data.frame(statement_id = "111", incorrect_count = NA_integer_), + "an incorrect_count of non-negative whole numbers" = + data.frame(statement_id = "111", incorrect_count = "1") + ) + for (i in seq_along(tables)) { + local_mocked_bindings(get_curations = function(backend, edges, ...) { + tables[[i]] + }) + expect_error(filter_by_curation(.make_curation_network()), + paste0("get_curations() for IndraBackend must return ", + names(tables)[i]), fixed = TRUE) + } + }) + + test_that("errors for a backend_database with no default backend", { + network <- .make_curation_network() + network$edges$backend_database <- "OmniPath" + expect_error(filter_by_curation(network), + 'No default backend for backend_database "OmniPath"', + fixed = TRUE) + }) + + test_that("errors without backend_database unless a backend is given", { + network <- .make_curation_network() + network$edges$backend_database <- NULL + expect_error(filter_by_curation(network), "no `backend_database` column") + }) + + test_that("checks its input before any request", { + local_mocked_bindings( + .get_incorrect_curation_count = function(...) stop("no request expected") + ) + network <- .make_curation_network() + expect_error(filter_by_curation(network$edges), "must be a list") + no_count <- network + no_count$edges$evidence_count <- NULL + expect_error(filter_by_curation(no_count), "evidence_count") + no_id <- network + no_id$nodes$id <- NULL + expect_error(filter_by_curation(no_id), "nodes` is missing the column: id") + expect_error(filter_by_curation(network, min_evidence = "2"), + "single number") + expect_error(filter_by_curation(network, min_evidence = c(1, 2)), + "single number") + expect_error(filter_by_curation(network, min_evidence = NA), + "single number") + expect_error(filter_by_curation(network, backend = "INDRA"), + "must be NULL or a NetworkBackend") + }) +}) diff --git a/tests/testthat/test-getSubnetworkFromIndra.R b/tests/testthat/test-getSubnetworkFromIndra.R index 4708aaf..33deb51 100644 --- a/tests/testthat/test-getSubnetworkFromIndra.R +++ b/tests/testthat/test-getSubnetworkFromIndra.R @@ -178,11 +178,13 @@ test_that("getSubnetworkFromIndra returns character node IDs when fread reads th }) test_that("getSubnetworkFromIndra subtracts incorrect curations when filter_by_curation = TRUE", { - local_mocked_bindings(.get_incorrect_curation_count = function(stmt_hash) 1) + local_mocked_bindings( + .get_incorrect_curation_count = function(statement_id, curation_url) 1 + ) suppressWarnings({ uncurated <- .run_mocked_subnetwork(statement_types = "Activation") - curated <- .run_mocked_subnetwork(statement_types = "Activation", - filter_by_curation = TRUE) + curated <- suppressMessages(.run_mocked_subnetwork( + statement_types = "Activation", filter_by_curation = TRUE)) }) kept <- uncurated$edges$evidence_count - 1L >= 1L expect_equal(curated$edges$statement_id, uncurated$edges$statement_id[kept]) @@ -192,6 +194,19 @@ test_that("getSubnetworkFromIndra subtracts incorrect curations when filter_by_c c(curated$edges$source, curated$edges$target))) }) +test_that("filter_by_curation() output of a real network meets the contract", { + local_mocked_bindings( + .get_incorrect_curation_count = function(statement_id, curation_url) 1 + ) + suppressWarnings(network <- .run_mocked_subnetwork( + statement_types = "Activation")) + result <- suppressMessages(filter_by_curation(network)) + expect_silent(validate_network(result)) + expect_lt(nrow(result$edges), nrow(network$edges)) + expect_true(all(result$nodes$id %in% + c(result$edges$source, result$edges$target))) +}) + # ----- Golden output (pinned after Phase 1 of the API refactor) ----- # Later phases must reproduce this output exactly. Regenerate the fixture From d42a441f1579fdb59dd9e287fcdfd40163bc58a2 Mon Sep 17 00:00:00 2001 From: Tony Wu Date: Wed, 7 Oct 2026 09:31:13 -0400 Subject: [PATCH 2/2] fix hashing issue --- NEWS.md | 4 +++- R/backend-indra-db.R | 8 ++++++-- tests/testthat/test-curation.R | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/NEWS.md b/NEWS.md index 632d094..85041a9 100644 --- a/NEWS.md +++ b/NEWS.md @@ -177,7 +177,9 @@ hard-coded URL. Its output is unchanged. `filter_by_curation()`. It looks up each statement hash once, where it used to look up every edge, uses the backend's `curation_url`, prints how many edges it drops, and also drops edges left with no evidence when -`evidence_count_cutoff` is below 1. +`evidence_count_cutoff` is below 1. The curation lookup reads evidence +hashes as character, so two hashes can no longer round to one number and +be counted once. # MSstatsBioNet 0.99.0 diff --git a/R/backend-indra-db.R b/R/backend-indra-db.R index 976375c..bccc49d 100644 --- a/R/backend-indra-db.R +++ b/R/backend-indra-db.R @@ -4,7 +4,8 @@ #' Count the evidence of a statement curated as incorrect #' #' Curations tagged anything other than \code{"correct"} count as -#' incorrect, once per evidence (\code{source_hash}). A failed request +#' incorrect, once per evidence (\code{source_hash}, read as character so +#' that no two hashes round to the same number). A failed request #' warns and counts as 0. #' #' @param statement_id INDRA statement hash @@ -22,7 +23,10 @@ tryCatch({ response <- GET(url) if (status_code(response) == 200) { - curations <- fromJSON(content(response, "text", encoding = "UTF-8")) + # source_hash values are above 2^53, so read them as character: + # as doubles, two different hashes can round to one value + curations <- fromJSON(content(response, "text", encoding = "UTF-8"), + bigint_as_char = TRUE) if (length(curations) == 0) { return(0) } diff --git a/tests/testthat/test-curation.R b/tests/testthat/test-curation.R index d9470fc..e8ea252 100644 --- a/tests/testthat/test-curation.R +++ b/tests/testthat/test-curation.R @@ -106,6 +106,21 @@ describe(".get_incorrect_curation_count()", { expect_equal(get_url(), "https://curation.example.org/curation/list/-123") }) + test_that("counts source hashes that differ only beyond double precision apart", { + # Both hashes round to the same double (spacing 1024 near 5e18) + local_mocked_bindings( + GET = function(url, ...) structure(list(), class = "response"), + status_code = function(x) 200, + content = function(x, ...) paste0( + '[{"tag": "hypothesis", "source_hash": -5331350000000000001},', + ' {"tag": "polarity", "source_hash": -5331350000000000002},', + ' {"tag": "polarity", "source_hash": -5331350000000000002},', + ' {"tag": "correct", "source_hash": -5331350000000000003}]') + ) + expect_identical(-5331350000000000001, -5331350000000000002) + expect_equal(.get_incorrect_curation_count("111"), 2) + }) + test_that("counts 0 when a statement has no curations", { .mock_curation_response(environment(), list()) expect_equal(.get_incorrect_curation_count("111"), 0)