from “Interpretation of thyroid-relevant bioactivity data for comparison to in vivo exposures: A prioritization approach for putative chemical inhibitors of in vitro deiodinase activity”
Kimberly T Truong, John F Wambaugh, Dustin F Kapraun, Sarah E Davidson-Fritz, Stephanie Eytcheson, Richard S Judson, and Katie Paul Friedman
Toxicological Sciences, 2025
In this vignette, we show how to use the full human gestational model from httk to perform in vitro-in vivo extrapolation (IVIVE) on a relevant in vitro bioactivity dataset. Briefly, this demonstrates how to take thyroid-related in vitro bioactivity data from ToxCast, perform IVIVE using the 3-compartment steady state model and the full human pregnancy model within httk, and compare it with the latest exposure estimates from ExpoCast as a method of prioritization. This is detailed in Truong et al. 2025 (in review).
Truong et al. 2025 describes a prioritization workflow for thyroid-related bioactivity data related to deiodinase inhibition by applying the full human gestational model to compute administered equivalent doses (AEDs) and bioactivity-to-exposure ratios (BERs) by integrating SEEM3 ExpoCast predictions. See Figure 1 for an overview of the workflow.
This vignette reproduces Figures 9 and 10 from Truong et al. 2025.
This vignette was created with httk v2.6.0 which includes new model functionality (see Simulate full-term human pregnancy model).
rm(list = ls())
Several chunks of code in this vignette have “eval = execute.vignette” and we set execute.vignette to be FALSE, by default. We do this to include all the code that was necessary to produce the figures but was not run when the vignette was built since some of these chunks take a considerable amount of time and the checks on CRAN limit how long we can spend building the package. You can still reproduce the end figures knitting the .Rmd file, since all the necessary and minimal data objects have been stored in httk. If you want to rerun the whole workflow on your own, you must rerun all code from top to bottom, either by changing execute.vignette to TRUE or clicking on the “green arrow” in RStudio.
<- FALSE execute.vignette
We need to load in the processed in vitro bioactivity data.
This is stored within httk and can be accessed by
httk::thyroid.ac50s
. From the R command prompt,
?thyroid.ac50s
for more details.
<- httk::thyroid.ac50s ac50.dt
library(httk)
library(data.table)
library(dplyr)
library(openxlsx)
library(ggpubr)
library(cowplot)
library(ggrepel)
library(latex2exp)
library(viridis)
library(scales)
library(ggstar)
Only 50 chemicals have experimental toxicokinetic (TK) parameters sufficient for httk.
# how many chems had experimental TK params?
<- subset(get_cheminfo(info = 'all', species = 'Human',
human.data.pre model = '3compartmentss'),
%in% ac50.dt$dsstox_substance_id)
DTXSID
nrow(human.data.pre)
We need to supplement these with in silico predictions of TK parameters.
# predict Clint/fup values for missing chems
# this loads new chem.physical_and_invitro.data table into the global env
load_dawson2021() # most data for ToxCast chems
load_sipes2017() # most data for pharma compounds
load_pradeep2020() # best ML method for in silico prediction
<- subset(ac50.dt, dsstox_substance_id %in% get_cheminfo(info = 'DTXSID',
human.httk.dt species = 'Human',
model = '3compartmentss',
physchem.exclude = FALSE,
median.only = TRUE))
<- setdiff(ac50.dt$dsstox_substance_id, human.httk.dt$dsstox_substance_id)
missing.dtxsids
View(ac50.dt[dsstox_substance_id %in% missing.dtxsids])
There are two ways to compute an oral AED in httk:
1. compute a steady-state plasma concentration via
calc_mc_css
and then: \[
AED = \frac{AC_{50}}{CSS_{50}}
\] 2. use calc_mc_oral_equiv
passing the AC50 to the
conc
argument and specifying
which.quantile = c(0.5)
.
for (i in 1:nrow(human.httk.dt)) {
message('Calculating for chemical: ', human.httk.dt$chnm[i], '\n')
set.seed(123)
<- calc_mc_css(dtxsid = human.httk.dt$dsstox_substance_id[i],
out species = 'Human',
daily.dose = 1,
which.quantile = c(0.5, 0.95),
model = '3compartmentss',
output.units = 'uM',
parameterize.args.list=list(physchem.exclude=FALSE),
suppress.messages = TRUE)
$mc.css50[i] <- out["50%"]
human.httk.dt
set.seed(123)
<- calc_mc_oral_equiv(conc = human.httk.dt$min_ac50[i],
oralequiv.out dtxsid = human.httk.dt$dsstox_substance_id[i],
species = 'Human',
which.quantile = c(0.5, 0.95),
input.units = 'uM',
output.units = 'mgpkgpday',
model = '3compartmentss',
restrictive.clearance = TRUE,
parameterize.args.list=list(physchem.exclude=FALSE),
suppress.messages = TRUE)
$oral_equiv50[i] <- oralequiv.out["50%"]
human.httk.dt
}
:= min_ac50/mc.css50]
human.httk.dt[, calc.aed50 := log10(oral_equiv50/calc.aed50)] human.httk.dt[, fold_diff50
# let's use the aed values from the division
<- human.httk.dt[, c('dsstox_substance_id', 'chnm',
human.httk.dt2 'DIO1', 'DIO2', 'DIO3', 'IYD', 'min_ac50',
'mc.css50', 'calc.aed50')]
<- httk::truong25.seem3
SEEM
# integrate SEEM3 predictions with merge
<- merge.data.table(human.httk.dt2,
human.httk.dt2.seem3 c("dsstox_substance_id", "seem3", "seem3.u95")],
SEEM[, by = c("dsstox_substance_id"),
all.x = TRUE)
:= calc.aed50/seem3.u95]
human.httk.dt2.seem3[, plasmaBER50 := log10(plasmaBER50)]
human.httk.dt2.seem3[, log.pBER50
<- human.httk.dt2.seem3
ivive.moe.tb setnames(ivive.moe.tb, "dsstox_substance_id", "dtxsid")
for (i in 1:nrow(ivive.moe.tb)) {
$half.life[i] <- calc_half_life(dtxsid = ivive.moe.tb$dtxsid[i], # t1/2 in hours
ivive.moe.tbphyschem.exclude = FALSE)
$half.life.days[i] <- ivive.moe.tb$half.life[i]/24 # in days
ivive.moe.tb }
# tissues where DIO enzymes live
<- c('Cliver', 'Cthyroid', 'Cfliver', 'Cfbrain', 'Cfthyroid',
impacted_tissues 'Cplacenta', 'Cconceptus', 'Cplasma')
<- c('DIO1', 'DIO2', 'DIO3', 'IYD')
targets
# max conc in every tissue for every chemical
<- data.frame(matrix(NA, ncol = length(impacted_tissues) + 1,
Cmax.tissues nrow = nrow(ivive.moe.tb),
dimnames = list(NULL, c('dtxsid', impacted_tissues))))
for(i in 1:nrow(ivive.moe.tb)) {
<- solve_full_pregnancy(dtxsid = ivive.moe.tb$dtxsid[i],
sol.out daily.dose = 1,
doses.per.day = 1,
time.course = seq(0, 40*7, 1/24), # view solution by hour
track.vars = impacted_tissues,
physchem.exclude = FALSE)
# get max of every column re: compt
<- apply(sol.out[, impacted_tissues], 2, max, na.rm = T)
Cmax.tissues[i, impacted_tissues] 'dtxsid'] <- ivive.moe.tb$dtxsid[i]
Cmax.tissues[i,
}
<- list()
tissue.aeds
# tissues specific for each target
<- list(DIO1 = c('Cliver', 'Cfliver', 'Cthyroid', 'Cfthyroid', 'Cconceptus'),
tissue_list DIO2 = c('Cthyroid', 'Cfthyroid', 'Cfbrain', 'Cconceptus'),
DIO3 = c('Cthyroid', 'Cfthyroid', 'Cfbrain', 'Cplacenta', 'Cconceptus'),
IYD = c('Cthyroid', 'Cfthyroid', 'Cconceptus'))
for (i in names(tissue_list)) {
<- which(!is.na(ivive.moe.tb[, ..i]))
active.chem.ind <- length(active.chem.ind)
n.chems
<- matrix(NA, nrow = n.chems, ncol = length(tissue_list[[i]]) + 1)
empty.mat <- data.frame(empty.mat)
df.target colnames(df.target) <- c('dtxsid', tissue_list[[i]])
2: (length(tissue_list[[i]])+1) ] <- mapply(function(x,y) x/y, ivive.moe.tb[active.chem.ind, ..i], Cmax.tissues[active.chem.ind, tissue_list[[i]]])
df.target[,
$dtxsid <- ivive.moe.tb$dtxsid[active.chem.ind]
df.target<- df.target
tissue.aeds[[i]] }
<- c('Cliver', 'Cthyroid', 'Cfliver', 'Cfbrain', 'Cfthyroid',
impacted_tissues 'Cplacenta', 'Cconceptus', 'Cplasma')
<- function(name, df) {
get_ber_data
$exposure <- ivive.moe.tb$seem3.u95[match(df$dtxsid, ivive.moe.tb$dtxsid)]
df<- df %>% select(-exposure) %>%
df.m ::melt(id.vars = c('dtxsid'),
reshape2variable.name = 'compt', value.name = 'aed')
$exposure <- ivive.moe.tb$seem3.u95[match(df.m$dtxsid, ivive.moe.tb$dtxsid)]
df.m$ber = df.m$aed/df.m$exposure
df.m<- df.m %>%
df.m2 select(-exposure) %>%
::melt(id.vars = c('dtxsid', 'compt', 'ber'))
reshape2
# add melted ExpoCast data as rows for each unique chem
<- df %>% select(dtxsid, exposure) %>%
expocast.dat ::melt(id.vars = c('dtxsid'))
reshape2
<- bind_rows(df.m2, expocast.dat)
df.m2 $value <- log10(df.m2$value)
df.m2$ber <- log10(df.m2$ber)
df.m2$chnm <- ivive.moe.tb$chnm[match(df.m2$dtxsid, ivive.moe.tb$dtxsid)]
df.m2$enzyme <- name
df.m2return(df.m2)
}
<- Map(get_ber_data, names(tissue.aeds), tissue.aeds)
new.list = bind_rows(new.list)
ggdata setDT(ggdata)
# order chemicals within each enzyme group by the most sensitive tissue/lifestage BER
:= min(ber, na.rm = TRUE), keyby = .(enzyme, dtxsid)]
ggdata[, min_ber <- ggdata[order(min_ber), .SD, keyby = .(enzyme)]
ggdata
# exposure values are dependent on dtxsid not httk compt
== 'exposure', compt := 'exposure']
ggdata[variable
# make a deep copy or else it will be modified by reference
<- copy(ggdata)
tissue.bers
# round all numerical values including BERs to 2 sigfigs
<- c("value", "ber", "min_ber")
num.cols c(num.cols) := lapply(.SD, signif, digits = 2), .SDcols = num.cols] tissue.bers[,
<- tissue.bers[min_ber <= 3, min(value), by = enzyme][, min(V1)]
min_val <- tissue.bers[min_ber <= 3, max(value), by = enzyme][, max(V1)]
max_val <- c(min_val, max_val)
ylims
# generate a set of 3 viridis colors for lifestage
# yellow represents neither maternal or fetal, i.e., conceptus
<- viridis(n = 3)
my_viridis_colors
<- list()
listgg for(k in names(tissue.aeds)) {
<- tissue.bers[enzyme == k & ber <= 3]
aeds <- tissue.bers[enzyme == k & ber <= 3, dtxsid]
chems <- tissue.bers[enzyme == k & variable == "exposure" & dtxsid %in% chems]
exposures
<- ggplot(data = rbind(aeds,exposures),
listgg[[k]] mapping = aes(x = reorder(chnm, -min_ber),
y = value)) +
geom_star(aes(starshape = compt, fill = compt, color = compt),
alpha = 0.65, size = 1.25, # default: 1.5
starstroke = 0.25, # default: 0.5
show.legend = T) +
scale_y_continuous(breaks = seq(ylims[1], ylims[2], 1),
limits = ylims) +
scale_starshape_manual(name = "Compartment corresponding to AED \n or Exposure",
labels = c("maternal liver", "fetal liver",
"maternal thyroid", "fetal thyroid",
"conceptus",
"fetal brain",
"placenta", "exposure"),
values = c('Cfbrain' = 15,
'exposure' = 28,
'Cliver' = 11,
'Cfliver' = 11,
'Cplacenta' = 13,
'Cthyroid' = 1,
'Cfthyroid' = 1,
'Cconceptus' = 23),
drop = F) + # this makes sure all levels of a factor are displayed even if unused
scale_fill_manual(name = "Compartment corresponding to AED \n or Exposure",
labels = c("maternal liver", "fetal liver",
"maternal thyroid", "fetal thyroid",
"conceptus",
"fetal brain",
"placenta", "exposure"),
values = c('Cfbrain' = my_viridis_colors[2],
'exposure' = 'dimgrey',
'Cliver' = my_viridis_colors[1],
'Cfliver' = my_viridis_colors[2],
'Cplacenta' = '#990066',
'Cthyroid' = my_viridis_colors[1],
'Cfthyroid' = my_viridis_colors[2],
'Cconceptus' = '#73D055FF'),
drop = F) +
scale_color_manual(name = "Compartment corresponding to AED \n or Exposure",
labels = c("maternal liver", "fetal liver",
"maternal thyroid", "fetal thyroid",
"conceptus",
"fetal brain",
"placenta", "exposure"),
values = c('Cfbrain' = my_viridis_colors[2],
'exposure' = 'dimgrey',
'Cliver' = my_viridis_colors[1],
'Cfliver' = my_viridis_colors[2],
'Cplacenta' = '#990066',
'Cthyroid' = my_viridis_colors[1],
'Cfthyroid' = my_viridis_colors[2],
'Cconceptus' = '#73D055FF'),
drop = F) +
labs(x = 'Chemical', y = 'Oral AED or Exposure (log10 mg/kg-bw/day)', title = k) +
theme_bw() +
theme(legend.position = "none",
plot.title = element_text(size = 8, face = "bold"),
plot.margin = unit(c(0.1,0.1,0.1,0.1), "cm"),
axis.title = element_text(size = 6),
axis.text.y = element_text(size = 5),
axis.text.x = element_text(size = 6),
legend.text = element_text(size = 6),
legend.title = element_text(size = 6, hjust = 0.5))+
coord_flip()
}
# remove extra axis labels
$IYD <- listgg$IYD + labs(x = '', y = '')
listgg$DIO3 <- listgg$DIO3 +
listgglabs(x = '')
$DIO1 <- listgg$DIO1 + labs(y = '')
listgg
# arrange DIO1 and DIO2 with equal sizes since they have ~20 chems each (21 vs 24)
<- plot_grid(listgg$DIO1, listgg$DIO2,
pcol1 align = "hv", nrow = 2,
rel_heights = c(1,1.1))
# make DIO3 3x taller than IYD (44 chems vs 4)
<- plot_grid(listgg$IYD, listgg$DIO3,
pcol2 align = "hv", nrow = 2,
rel_heights = c(1,3))
# combine two columns of plots
<- plot_grid(pcol1, pcol2,
prow align = "h", ncol = 2,
rel_widths = c(1,1))
# extract legend from one of the plots
<- ggplotGrob(
grobs $DIO2 +
listggguides(
starshape = guide_legend(ncol = 2, byrow = F,
keyheight = unit(0.25, "cm")),
fill = guide_legend(ncol = 2, byrow = F,
keyheight = unit(0.25, "cm")),
color = guide_legend(ncol = 2, byrow = F,
keyheight = unit(0.25, "cm")),
+
) theme(legend.position = "bottom")
$grobs
)<- grobs[[which(sapply(grobs, function(x) x$name) == "guide-box")]]
legend2
# add the legend underneath the plots; make it 10% of the height of the combined plots
<- plot_grid(prow, legend2,
fig9 nrow = 2, rel_heights = c(0.88,0.12)) +
theme(plot.background = element_rect(fill = "white", color = "white"))
# ggsave(plot = fig9,
# units = "mm",
# dpi = 300,
# width = 190, height = 150,
# device = "png",
# filename = "./figures/preg_prioritization_by_BER.png")
<- c('DIO1', 'DIO2', 'DIO3', 'IYD')
targets
# DATA WRANGLING
== ber, most_sensitive_tissue := compt]
tissue.bers[min_ber
# table matching order of chemicals in enzyme subplots with all calculated BERs
# every enzyme x chem x min BER constitutes a row
<- tissue.bers[!is.na(most_sensitive_tissue),
minber.by.chem = .(enzyme, dtxsid)]
.(chnm, min_ber, most_sensitive_tissue), by
# check if there are multiple most-sensitive tissues per chem in each enzyme group
if(.N > 1) .SD, by = .(enzyme, dtxsid)][, length(unique(dtxsid))] #70 chems
minber.by.chem[,
# collapse multiple most-sensitive tissues per chem in each enzyme group
<- minber.by.chem[, most_sensitive_compts := paste(most_sensitive_tissue, collapse = ", "), by = .(enzyme, dtxsid)][, .(chnm, min_ber, most_sensitive_compts), by = .(enzyme, dtxsid)]
min.ber.reduced <- unique(min.ber.reduced)
min.ber.reduced
# min BER matrix
<- data.table::dcast(min.ber.reduced[, 1:4],
min.ber.mat + chnm ~ enzyme, value.var = c('min_ber'))
dtxsid := pmin(DIO1, DIO2, DIO3, IYD, na.rm = T)]
min.ber.mat[, min_across_enzyme <- min.ber.mat[order(min_across_enzyme)]
min.ber.mat
# find the target corresponding to the min_across_enzyme
:= mapply(function(x) targets[x], apply(min.ber.mat[, targets, with = FALSE], 1, which.min))]
min.ber.mat[, enzyme
# merge data on most sensitive tissues reflecting min BER
<- merge.data.table(min.ber.mat,
new.ranking.tissues
minber.by.chem[, .(dtxsid, chnm, enzyme, min_ber, most_sensitive_tissue)], by = c('dtxsid', 'chnm', 'enzyme'),
all.x = TRUE)
setnames(new.ranking.tissues, old = 'enzyme', new = 'target_min')
# round all BERs to 2 sigfigs for easy table viewing
<- ivive.moe.tb[, lmoe50 := signif(log.pBER50, digits = 2)][order(lmoe50)]
ranked.by.plasma
<- new.ranking.tissues[, -c("target_min", "min_ber")]
final.new.ranking <- final.new.ranking[order(min_across_enzyme)]
final.new.ranking
# comparing tissue BERs from full preg model with plasma BERs from 3compss model
<- merge.data.table(ranked.by.plasma[, c('dtxsid', 'chnm', 'lmoe50')],
plasma.tissue.bers -c('chnm')],
final.new.ranking[, by = c('dtxsid'))
setnames(plasma.tissue.bers, old = colnames(plasma.tissue.bers)[3:8],
new = c('plasma_BER50', 'DIO1_BER', 'DIO2_BER', 'DIO3_BER', 'IYD_BER', 'min_BER'))
<- plasma.tissue.bers[order(min_BER)] plasma.tissue.bers
# merge min BER with plasma BER for each chem with unique row for each chem
# ignoring multiple sensitive tissues
<- merge.data.table(ranked.by.plasma[, c('dtxsid', 'chnm', 'lmoe50')],
ber.comp -c("chnm")],
min.ber.mat[, by = c("dtxsid"))
setnames(ber.comp, old = colnames(ber.comp)[3:8],
new = c('plasma_BER50', 'DIO1_BER', 'DIO2_BER', 'DIO3_BER', 'IYD_BER', 'min_BER'))
<- max(ber.comp[, c('plasma_BER50', 'min_BER')], na.rm = T)
max_ber <- min(ber.comp[, c('plasma_BER50', 'min_BER')], na.rm = T)
min_ber <- c(floor(min_ber), ceiling(max_ber))
ber.lims
# chems with labels
<- ber.comp[min_BER < 0][order(min_BER)][1:6, chnm]
chem.labels
# theme for the next 2 figures
<- theme_bw() +
my_theme theme(axis.title = element_text(size = 8, face = "bold"),
axis.text = element_text(size = 7))
<- ggplot(data = ber.comp, aes(x = plasma_BER50, y = min_BER)) +
preg.vs.nonpreg.pp geom_point(alpha = 0.75, size = 1,
show.legend = T) +
geom_abline(slope = 1, linewidth = 0.5, linetype = 'solid', color = "#666666") +
geom_abline(aes(slope = 1, intercept = 1), linetype = 'longdash', color = 'grey', linewidth = 0.5) + # default linesize = 1
geom_abline(aes(slope = 1, intercept = -1), linetype = 'longdash', color = 'grey', linewidth = 0.5) +
geom_label_repel(aes(label = chnm),
data = ber.comp[chnm %in% chem.labels],
max.overlaps = Inf,
force = 50,
size = 2,
label.padding = 0.1,
label.size = 0.1,
label.r = 0.08,
segment.size = 0.2,
min.segment.length = 0) +
+
my_theme scale_x_continuous(breaks = seq(ber.lims[1], ber.lims[2], 1),
limits = ber.lims) +
scale_y_continuous(breaks = seq(ber.lims[1], ber.lims[2], 1),
limits = ber.lims) +
labs(x = 'plasma BER using 3compss model', y = 'min BER across DIO/IYD targets and tissues')
# how many chems to the right of y = x?
> min_BER, .N] #89 chems
ber.comp[plasma_BER50
# plot SEEM3 uncertainty
$uncertainty = log10(ivive.moe.tb$seem3.u95/ivive.moe.tb$seem3)
ivive.moe.tb$uncertainty <- ivive.moe.tb$uncertainty[match(ber.comp$dtxsid, ivive.moe.tb$dtxsid)]
ber.comp
<- min(ber.comp$min_BER)
min.min.ber <- max(ber.comp$min_BER)
max.min.ber <- c(floor(min.min.ber), ceiling(max.min.ber))
min.ber.lims <- max(ber.comp$uncertainty)
max.uncertainty
<- ggplot(data = ber.comp,
seem3pp mapping = aes(x = min_BER,
y = uncertainty)) +
geom_point(size = 1, alpha = 0.8) +
geom_label_repel(aes(label = chnm),
data = subset(ber.comp, chnm %in% chem.labels),
max.overlaps = Inf,
force = 75,
size = 2,
label.padding = 0.1,
label.size = 0.1,
label.r = 0.08,
segment.size = 0.2,
min.segment.length = 0) +
+
my_theme scale_x_continuous(breaks = seq(min.ber.lims[1], min.ber.lims[2], 1),
limits = min.ber.lims) +
scale_y_continuous(breaks = seq(0, ceiling(max.uncertainty), 1),
limits = c(0, ceiling(max.uncertainty))) +
labs(x = 'min BER by Chemical', y = TeX(r'($log_{10}ExpoCast_{u95} - log_{10}ExpoCast_{med}$)'))
median(ber.comp$min_BER)
#> [1] 2.9
<- plot_grid(preg.vs.nonpreg.pp, seem3pp,
fig10 labels = "AUTO", label_size = 10)
# ggsave(plot = fig10,
# units = "mm",
# dpi = 300,
# width = 190, height = 100,
# device = "png",
# filename = "./figures/BER_uncertainty.png")
<- ac50.dt
thyroid.ac50s <- SEEM
truong25.seem3
save(thyroid.ac50s, truong25.seem3,
file="./httk/Truong2025Vignette.RData")