mirror of
https://github.com/msberends/AMR.git
synced 2026-03-11 15:47:54 +01:00
* mdro(): infer base drug resistance from drug+inhibitor combination columns (#209) When a base beta-lactam column (e.g., piperacillin/PIP) is absent but a corresponding drug+inhibitor combination (e.g., piperacillin/tazobactam/TZP) is present and resistant, resistance in the base drug is now correctly inferred. This is clinically sound: resistance in a combination implies the inhibitor provided no benefit, so the base drug is also resistant. Susceptibility in a combination is NOT propagated to the base drug (the inhibitor may be responsible for susceptibility), so only R values are inferred; missing base drugs remain NA otherwise. Implementation details: - Uses AB_BETALACTAMS_WITH_INHIBITOR to identify all beta-lactam+inhibitor combinations present in the user's data - Derives base drug AB codes by stripping the "/inhibitor" part from names - Creates synthetic proxy columns (.sir_proxy_<AB>) in x, set to "R" when any matching combination is R, otherwise NA - Proxy columns are added to cols_ab before drug variable assignment, so all existing guideline logic benefits without any changes - Multiple combos for the same base drug are OR-ed (any R → R) - Adds internal ab_without_inhibitor() helper for the name->base mapping - Verbose mode reports which combinations are used for inference Bumps version: 3.0.1.9028 -> 3.0.1.9029 https://claude.ai/code/session_01Cp154UtssHg84bw38xiiTG * Add sir.R/mic.R fixes and mdro() unit tests; bump to 3.0.1.9030 R/sir.R (line 571): Guard purely numeric strings (e.g. "1", "8") from the Unicode letter filter. Values matching the broad SIR regex but consisting only of digits must not be stripped; add `x %unlike% "^[0-9+]$"` predicate. R/mic.R (lines 220-222): Preserve the letter 'e' during Unicode-letter removal so that MIC values in scientific notation (e.g. "1e-3", "2.5e-2") survive the cleaning step. - Line 220: [\\p{L}] → [^e\\P{L}] (remove all letters except 'e') - Line 222: [^0-9.><= -]+ → [^0-9e.><= -]+ (allow 'e' in whitelist) tests/testthat/test-mdro.R: New tests for the drug+inhibitor inference added in the previous commit (issue #209): - TZP=R with no PIP column → PIP inferred R → MDRO class elevated - TZP=S with no PIP column → proxy col is NA (not S) → class lower - verbose mode emits "Inferring resistance" message - AMC=R with no AMX column runs without error (Enterococcus faecium) https://claude.ai/code/session_01Cp154UtssHg84bw38xiiTG * Fix version to single bump (9029) and update CLAUDE.md versioning rules CLAUDE.md: Rewrite the "Version and date bump" subsection to document that: - Exactly ONE version bump is allowed per PR (PRs are squash-merged into one commit on the default branch, so one commit = one version increment) - The correct version is computed from git history: currentversion="${currenttag}.$((commits_since_tag + 9001 + 1))" with the +1 accounting for the PR's own squash commit not yet on the default branch - Fall back to incrementing DESCRIPTION's version by 1 if git describe fails - The Date: field tracks the date of the *last* PR commit (updated each time) DESCRIPTION / NEWS.md: Correct the version from 3.0.1.9030 back to 3.0.1.9029. Two version bumps were made across two commits in this PR; since it will be squash-merged as one commit only one bump is correct. Also update Date to today (2026-03-07). https://claude.ai/code/session_01Cp154UtssHg84bw38xiiTG * Fix stats::setNames, test accessor bug, and version script verification R/mdro.R: Qualify setNames() as stats::setNames() in the drug+inhibitor inference block to satisfy R CMD CHECK's global-function checks. tests/testthat/test-mdro.R: mdro() with verbose=FALSE returns an atomic ordered factor, not a data.frame. Fix three test errors introduced in the previous commit: - Line 320: result_no_pip$MDRO -> result_no_pip (factor, no $ accessor) - Line 328: result_tzp_s$MDRO / result_no_pip$MDRO -> direct factor refs - Line 347: expect_inherits(..., "data.frame") -> c("factor","ordered") Also fix the comment on line 347 to match the actual return type. Version: confirmed at 3.0.1.9029 (no further bump; one bump already made this PR). git describe failed (no tags in dev environment) — fallback applies. The +1 in CLAUDE.md's formula is correct for tagged repos: currentcommit + 9001 + 1 = 27 + 9001 + 1 = 9029 ✓ https://claude.ai/code/session_01Cp154UtssHg84bw38xiiTG * Fix unit tests: use mrgn guideline and expect_message() for proxy tests Three failures corrected: 1. Classification tests (lines 321, 329): The EUCAST guideline for P. aeruginosa already has OR logic (PIP OR TZP), so TZP=R alone satisfies it regardless of whether the PIP proxy exists. Switch to guideline="mrgn": the MRGN 4MRGN criterion for P. aeruginosa requires PIP=R explicitly (lines 1488-1496 of mdro.R), with no TZP fallback. Without the proxy: PIP missing -> not 4MRGN -> level 1. With the proxy (TZP=R infers PIP=R): 4MRGN reached -> level 3. The TZP=S case leaves proxy=NA, so PIP is still absent effectively -> level 1, which is < level 3 as expected. 2. Verbose/message test (line 335): message_() routes through message() to stderr, not cat() to stdout. expect_output() only captures stdout so it always saw nothing. Fix: use expect_message() instead, and remove the inner suppressMessages() that was swallowing the message before expect_message() could capture it. Also trim two stale lines left over from the old expect_output block. https://claude.ai/code/session_01Cp154UtssHg84bw38xiiTG --------- Co-authored-by: Claude <noreply@anthropic.com>
346 lines
14 KiB
R
Executable File
346 lines
14 KiB
R
Executable File
# ==================================================================== #
|
|
# TITLE: #
|
|
# AMR: An R Package for Working with Antimicrobial Resistance Data #
|
|
# #
|
|
# SOURCE CODE: #
|
|
# https://github.com/msberends/AMR #
|
|
# #
|
|
# PLEASE CITE THIS SOFTWARE AS: #
|
|
# Berends MS, Luz CF, Friedrich AW, et al. (2022). #
|
|
# AMR: An R Package for Working with Antimicrobial Resistance Data. #
|
|
# Journal of Statistical Software, 104(3), 1-31. #
|
|
# https://doi.org/10.18637/jss.v104.i03 #
|
|
# #
|
|
# Developed at the University of Groningen and the University Medical #
|
|
# Center Groningen in The Netherlands, in collaboration with many #
|
|
# colleagues from around the world, see our website. #
|
|
# #
|
|
# This R package is free software; you can freely use and distribute #
|
|
# it for both personal and commercial purposes under the terms of the #
|
|
# GNU General Public License version 2.0 (GNU GPL-2), as published by #
|
|
# the Free Software Foundation. #
|
|
# We created this package for both routine data analysis and academic #
|
|
# research and it was publicly released in the hope that it will be #
|
|
# useful, but it comes WITHOUT ANY WARRANTY OR LIABILITY. #
|
|
# #
|
|
# Visit our website for the full manual and a complete tutorial about #
|
|
# how to conduct AMR data analysis: https://amr-for-r.org #
|
|
# ==================================================================== #
|
|
|
|
test_that("test-mdro.R", {
|
|
skip_on_cran()
|
|
|
|
expect_error(mdro(example_isolates, guideline = c("BRMO", "MRGN"), info = TRUE))
|
|
expect_error(mdro(example_isolates, col_mo = "invalid", info = TRUE))
|
|
|
|
expect_output(suppressMessages(suppressWarnings(mdro(example_isolates, info = TRUE))))
|
|
expect_output(suppressMessages(suppressWarnings(mdro(example_isolates, "eucast3.1", info = TRUE))))
|
|
expect_output(suppressMessages(suppressWarnings(mdro(example_isolates, "eucast3.2", info = TRUE))))
|
|
expect_output(suppressMessages(suppressWarnings(mdro(example_isolates, "eucast3.3", info = TRUE))))
|
|
expect_output(outcome <- suppressMessages(suppressWarnings(eucast_exceptional_phenotypes(example_isolates, info = TRUE))))
|
|
# check class
|
|
expect_identical(class(outcome), c("ordered", "factor"))
|
|
|
|
expect_output(outcome <- mdro(example_isolates, "nl", info = TRUE))
|
|
# check class
|
|
expect_identical(class(outcome), c("ordered", "factor"))
|
|
|
|
# example_isolates should have these finding using Dutch guidelines
|
|
expect_equal(
|
|
as.double(table(outcome)),
|
|
c(1977, 21, 2)
|
|
)
|
|
|
|
expect_equal(
|
|
brmo(example_isolates, info = FALSE),
|
|
mdro(example_isolates, guideline = "BRMO", info = FALSE)
|
|
)
|
|
|
|
# test Dutch P. aeruginosa MDRO
|
|
expect_equal(
|
|
as.character(mdro(
|
|
data.frame(
|
|
mo = as.mo("P. aeruginosa"),
|
|
cfta = "S",
|
|
cipr = "S",
|
|
mero = "S",
|
|
imip = "S",
|
|
gent = "S",
|
|
tobr = "S",
|
|
pita = "S"
|
|
),
|
|
guideline = "BRMO",
|
|
col_mo = "mo",
|
|
info = FALSE
|
|
)),
|
|
"Negative"
|
|
)
|
|
expect_equal(
|
|
as.character(mdro(
|
|
data.frame(
|
|
mo = as.mo("P. aeruginosa"),
|
|
cefta = "R",
|
|
cipr = "R",
|
|
mero = "R",
|
|
imip = "R",
|
|
gent = "R",
|
|
tobr = "R",
|
|
pita = "R"
|
|
),
|
|
guideline = "BRMO",
|
|
col_mo = "mo",
|
|
info = FALSE
|
|
)),
|
|
"Positive"
|
|
)
|
|
|
|
# German 3MRGN and 4MRGN
|
|
expect_equal(
|
|
as.character(mrgn(
|
|
data.frame(
|
|
mo = c(
|
|
"E. coli", "E. coli", "K. pneumoniae", "E. coli",
|
|
"A. baumannii", "A. baumannii", "A. baumannii",
|
|
"P. aeruginosa", "P. aeruginosa", "P. aeruginosa"
|
|
),
|
|
PIP = c(
|
|
"S", "R", "R", "S",
|
|
"S", "R", "R",
|
|
"S", "R", "R"
|
|
),
|
|
CTX = c(
|
|
"S", "R", "R", "S",
|
|
"R", "R", "R",
|
|
"R", "R", "R"
|
|
),
|
|
IPM = c(
|
|
"S", "R", "S", "R",
|
|
"R", "R", "S",
|
|
"S", "R", "R"
|
|
),
|
|
CIP = c(
|
|
"S", "R", "R", "S",
|
|
"R", "R", "R",
|
|
"R", "S", "R"
|
|
),
|
|
stringsAsFactors = FALSE
|
|
)
|
|
)),
|
|
c("Negative", "4MRGN", "3MRGN", "4MRGN", "4MRGN", "4MRGN", "3MRGN", "Negative", "3MRGN", "4MRGN")
|
|
)
|
|
|
|
# MDR TB
|
|
expect_equal(
|
|
# select only rifampicine, mo will be determined automatically (as M. tuberculosis),
|
|
# number of mono-resistant strains should be equal to number of rifampicine-resistant strains
|
|
as.double(table(mdr_tb(example_isolates[, "RIF", drop = FALSE])))[2],
|
|
count_R(example_isolates$RIF)
|
|
)
|
|
|
|
x <- data.frame(
|
|
rifampicin = random_sir(5000, prob_sir = c(0.4, 0.1, 0.5)),
|
|
inh = random_sir(5000, prob_sir = c(0.4, 0.1, 0.5)),
|
|
gatifloxacin = random_sir(5000, prob_sir = c(0.4, 0.1, 0.5)),
|
|
eth = random_sir(5000, prob_sir = c(0.4, 0.1, 0.5)),
|
|
pza = random_sir(5000, prob_sir = c(0.4, 0.1, 0.5)),
|
|
MFX = random_sir(5000, prob_sir = c(0.4, 0.1, 0.5)),
|
|
KAN = random_sir(5000, prob_sir = c(0.4, 0.1, 0.5))
|
|
)
|
|
expect_true(length(unique(mdr_tb(x))) > 2)
|
|
|
|
# check the guideline by Magiorakos et al. (2012), the default guideline
|
|
stau <- data.frame(
|
|
mo = c("S. aureus", "S. aureus", "S. aureus", "S. aureus"),
|
|
GEN = c("R", "R", "S", "R"),
|
|
RIF = c("S", "R", "S", "R"),
|
|
CPT = c("S", "R", "R", "R"),
|
|
OXA = c("S", "R", "R", "R"),
|
|
CIP = c("S", "S", "R", "R"),
|
|
MFX = c("S", "S", "R", "R"),
|
|
SXT = c("S", "S", "R", "R"),
|
|
FUS = c("S", "S", "R", "R"),
|
|
VAN = c("S", "S", "R", "R"),
|
|
TEC = c("S", "S", "R", "R"),
|
|
TLV = c("S", "S", "R", "R"),
|
|
TGC = c("S", "S", "R", "R"),
|
|
CLI = c("S", "S", "R", "R"),
|
|
DAP = c("S", "S", "R", "R"),
|
|
ERY = c("S", "S", "R", "R"),
|
|
LNZ = c("S", "S", "R", "R"),
|
|
CHL = c("S", "S", "R", "R"),
|
|
FOS = c("S", "S", "R", "R"),
|
|
QDA = c("S", "S", "R", "R"),
|
|
TCY = c("S", "S", "R", "R"),
|
|
DOX = c("S", "S", "R", "R"),
|
|
MNO = c("S", "S", "R", "R"),
|
|
stringsAsFactors = FALSE
|
|
)
|
|
expect_equal(as.integer(mdro(stau)), c(1:4))
|
|
expect_inherits(mdro(stau, verbose = TRUE), "data.frame")
|
|
|
|
ente <- data.frame(
|
|
mo = c("Enterococcus", "Enterococcus", "Enterococcus", "Enterococcus"),
|
|
GEH = c("R", "R", "S", "R"),
|
|
STH = c("S", "R", "S", "R"),
|
|
IPM = c("S", "R", "R", "R"),
|
|
MEM = c("S", "R", "R", "R"),
|
|
DOR = c("S", "S", "R", "R"),
|
|
CIP = c("S", "S", "R", "R"),
|
|
LVX = c("S", "S", "R", "R"),
|
|
MFX = c("S", "S", "R", "R"),
|
|
VAN = c("S", "S", "R", "R"),
|
|
TEC = c("S", "S", "R", "R"),
|
|
TGC = c("S", "S", "R", "R"),
|
|
DAP = c("S", "S", "R", "R"),
|
|
LNZ = c("S", "S", "R", "R"),
|
|
AMP = c("S", "S", "R", "R"),
|
|
QDA = c("S", "S", "R", "R"),
|
|
DOX = c("S", "S", "R", "R"),
|
|
MNO = c("S", "S", "R", "R"),
|
|
stringsAsFactors = FALSE
|
|
)
|
|
expect_equal(as.integer(mdro(ente)), c(1:4))
|
|
expect_inherits(mdro(ente, verbose = TRUE), "data.frame")
|
|
|
|
entero <- data.frame(
|
|
mo = c("E. coli", "E. coli", "E. coli", "E. coli"),
|
|
GEN = c("R", "R", "S", "R"), TOB = c("S", "R", "S", "R"),
|
|
AMK = c("S", "R", "R", "R"), NET = c("S", "R", "R", "R"),
|
|
CPT = c("S", "R", "R", "R"), TCC = c("S", "R", "R", "R"),
|
|
TZP = c("S", "S", "R", "R"), ETP = c("S", "S", "R", "R"),
|
|
IPM = c("S", "S", "R", "R"), MEM = c("S", "S", "R", "R"),
|
|
DOR = c("S", "S", "R", "R"), CZO = c("S", "S", "R", "R"),
|
|
CXM = c("S", "S", "R", "R"), CTX = c("S", "S", "R", "R"),
|
|
CAZ = c("S", "S", "R", "R"), FEP = c("S", "S", "R", "R"),
|
|
FOX = c("S", "S", "R", "R"), CTT = c("S", "S", "R", "R"),
|
|
CIP = c("S", "S", "R", "R"), SXT = c("S", "S", "R", "R"),
|
|
TGC = c("S", "S", "R", "R"), ATM = c("S", "S", "R", "R"),
|
|
AMP = c("S", "S", "R", "R"), AMC = c("S", "S", "R", "R"),
|
|
SAM = c("S", "S", "R", "R"), CHL = c("S", "S", "R", "R"),
|
|
FOS = c("S", "S", "R", "R"), COL = c("S", "S", "R", "R"),
|
|
TCY = c("S", "S", "R", "R"), DOX = c("S", "S", "R", "R"),
|
|
MNO = c("S", "S", "R", "R"),
|
|
stringsAsFactors = FALSE
|
|
)
|
|
expect_equal(as.integer(mdro(entero)), c(1:4))
|
|
expect_inherits(mdro(entero, verbose = TRUE), "data.frame")
|
|
|
|
pseud <- data.frame(
|
|
mo = c("P. aeruginosa", "P. aeruginosa", "P. aeruginosa", "P. aeruginosa"),
|
|
GEN = c("R", "R", "S", "R"), TOB = c("S", "S", "S", "R"),
|
|
AMK = c("S", "S", "R", "R"), NET = c("S", "S", "R", "R"),
|
|
IPM = c("S", "R", "R", "R"), MEM = c("S", "S", "R", "R"),
|
|
DOR = c("S", "S", "R", "R"), CAZ = c("S", "S", "R", "R"),
|
|
FEP = c("S", "R", "R", "R"), CIP = c("S", "S", "R", "R"),
|
|
LVX = c("S", "S", "R", "R"), TCC = c("S", "S", "R", "R"),
|
|
TZP = c("S", "S", "R", "R"), ATM = c("S", "S", "R", "R"),
|
|
FOS = c("S", "S", "R", "R"), COL = c("S", "S", "R", "R"),
|
|
PLB = c("S", "S", "R", "R"),
|
|
stringsAsFactors = FALSE
|
|
)
|
|
expect_equal(as.integer(mdro(pseud)), c(1:4))
|
|
expect_inherits(mdro(pseud, verbose = TRUE), "data.frame")
|
|
|
|
acin <- data.frame(
|
|
mo = c("A. baumannii", "A. baumannii", "A. baumannii", "A. baumannii"),
|
|
GEN = c("R", "R", "S", "R"), TOB = c("S", "R", "S", "R"),
|
|
AMK = c("S", "R", "R", "R"), NET = c("S", "R", "R", "R"),
|
|
IPM = c("S", "S", "R", "R"), MEM = c("S", "R", "R", "R"),
|
|
DOR = c("S", "S", "R", "R"), CIP = c("S", "S", "R", "R"),
|
|
LVX = c("S", "S", "R", "R"), TZP = c("S", "S", "R", "R"),
|
|
TCC = c("S", "S", "R", "R"), CTX = c("S", "S", "R", "R"),
|
|
CRO = c("S", "S", "R", "R"), CAZ = c("S", "S", "R", "R"),
|
|
FEP = c("S", "R", "R", "R"), SXT = c("S", "S", "R", "R"),
|
|
SAM = c("S", "S", "R", "R"), COL = c("S", "S", "R", "R"),
|
|
PLB = c("S", "S", "R", "R"), TCY = c("S", "S", "R", "R"),
|
|
DOX = c("S", "S", "R", "R"), MNO = c("S", "S", "R", "R"),
|
|
stringsAsFactors = FALSE
|
|
)
|
|
expect_equal(as.integer(mdro(acin)), c(1:4))
|
|
expect_inherits(mdro(acin, verbose = TRUE), "data.frame")
|
|
|
|
# custom rules
|
|
custom <- custom_mdro_guideline("CIP == 'R' & age > 60" ~ "Elderly Type A",
|
|
"ERY == 'R' & age > 60" ~ "Elderly Type B",
|
|
as_factor = TRUE
|
|
)
|
|
expect_output(print(custom))
|
|
expect_output(print(c(custom, custom)))
|
|
expect_output(print(as.list(custom, custom)))
|
|
|
|
expect_output(x <- mdro(example_isolates, guideline = custom, info = TRUE))
|
|
expect_equal(as.double(table(x)), c(1070, 198, 732))
|
|
|
|
expect_output(print(custom_mdro_guideline(AMX == "R" ~ "test", as_factor = FALSE)))
|
|
expect_error(custom_mdro_guideline())
|
|
expect_error(custom_mdro_guideline("test"))
|
|
expect_error(custom_mdro_guideline("test" ~ c(1:3)))
|
|
expect_error(custom_mdro_guideline("test" ~ A))
|
|
# expect_warning(mdro(example_isolates,
|
|
# # since `test` gives an error, it will be ignored with a warning
|
|
# guideline = custom_mdro_guideline(test ~ "A"),
|
|
# info = FALSE
|
|
# ))
|
|
|
|
expect_equal(
|
|
colnames(suppressWarnings(mdro(example_isolates[1:10, ], verbose = TRUE, info = FALSE))),
|
|
c("row_number", "microorganism", "MDRO", "reason", "all_nonsusceptible_columns", "guideline")
|
|
)
|
|
expect_equal(
|
|
colnames(suppressWarnings(mdro(example_isolates[1:10, ], verbose = TRUE, info = FALSE, guideline = custom_mdro_guideline(AMX == "R" ~ "Positive")))),
|
|
c("row_number", "microorganism", "MDRO", "reason", "all_nonsusceptible_columns", "guideline")
|
|
)
|
|
|
|
# print groups
|
|
if (AMR:::pkg_is_available("dplyr", min_version = "1.0.0", also_load = TRUE)) {
|
|
expect_output(x <- mdro(example_isolates %>% group_by(ward), info = TRUE, pct_required_classes = 0))
|
|
expect_output(x <- mdro(example_isolates %>% group_by(ward), guideline = custom, info = TRUE))
|
|
}
|
|
|
|
# drug+inhibitor inference for missing base drug columns (issue #209) -------
|
|
# Resistance in drug+inhibitor implies resistance in the base drug.
|
|
# MRGN guideline is used because it explicitly requires PIP=R (not PIP OR TZP)
|
|
# for Pseudomonas aeruginosa 4MRGN, making the proxy effect directly testable.
|
|
pseud_no_pip <- data.frame(
|
|
mo = as.mo("Pseudomonas aeruginosa"),
|
|
TZP = as.sir("R"), # piperacillin/tazobactam; no PIP column
|
|
CAZ = as.sir("R"),
|
|
IPM = as.sir("R"),
|
|
MEM = as.sir("R"),
|
|
CIP = as.sir("R"),
|
|
stringsAsFactors = FALSE
|
|
)
|
|
# Inference message goes to message() / stderr, not stdout
|
|
# -> must use expect_message(), NOT expect_output()
|
|
expect_message(
|
|
suppressWarnings(mdro(pseud_no_pip, guideline = "mrgn", info = FALSE, verbose = TRUE)),
|
|
"Inferring resistance"
|
|
)
|
|
# With TZP=R, PIP is inferred R -> 4MRGN criteria met -> level 3 (> 1)
|
|
result_no_pip <- suppressMessages(suppressWarnings(
|
|
mdro(pseud_no_pip, guideline = "mrgn", info = FALSE)
|
|
))
|
|
expect_true(as.integer(result_no_pip) > 1L)
|
|
# Susceptibility in combo does NOT propagate: proxy = NA, not S
|
|
# -> 4MRGN criteria no longer met -> lower level than when TZP=R
|
|
pseud_tzp_s <- pseud_no_pip
|
|
pseud_tzp_s$TZP <- as.sir("S")
|
|
result_tzp_s <- suppressMessages(suppressWarnings(
|
|
mdro(pseud_tzp_s, guideline = "mrgn", info = FALSE)
|
|
))
|
|
expect_true(as.integer(result_tzp_s) < as.integer(result_no_pip))
|
|
|
|
# Multiple combos for the same base drug: AMX can come from AMC (amoxicillin/clavulanic acid)
|
|
ente_no_amx <- data.frame(
|
|
mo = as.mo("Enterococcus faecium"),
|
|
AMC = as.sir("R"), # amoxicillin/clavulanic acid; no AMX column
|
|
VAN = as.sir("R"),
|
|
TEC = as.sir("R"),
|
|
LNZ = as.sir("R"),
|
|
DAP = as.sir("R"),
|
|
stringsAsFactors = FALSE
|
|
)
|
|
# Should run without error and return an ordered factor; AMX inferred R from AMC
|
|
expect_inherits(suppressMessages(suppressWarnings(mdro(ente_no_amx, guideline = "EUCAST", info = FALSE))), c("factor", "ordered"))
|
|
})
|