--- title: "Dose-Response Analysis" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Dose-Response Analysis} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} resource_files: - figures/logo.svg - figures/logo-dark.svg --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 4, fig.align = "center") options(width = 84) library(cellreportR) ``` `cr_dose_response()` fits three standard models to unit-level summaries of one channel across exposure levels: * **4PL** — four-parameter logistic in `log10(dose)`, with a free lower asymptote; * **3PL** — the same shape with the lower asymptote fixed at zero; * **linear** — `y ~ x`, for a readout that has no plateau in the range measured. ## Setting up an exposure axis Any `cr_experiment` whose `design` carries a numeric `dose` column is eligible. The demonstration experiment has one already, but its levels are tied to treatment names rather than spread along an axis, so we widen it into six exposure levels. ```{r setup-doses} exp <- cr_example_experiment(seed = 3, n_cells_per_well = 60) exp$design$dose <- c(Untreated = 1, CompoundB = 10, CompoundC = 30, CompoundA_low = 100, CompoundA_high = 300, PosControl = 1000)[exp$design$treatment] table(exp$design$dose) ``` Doses must be strictly positive when `log_dose = TRUE`; a zero-dose vehicle level has no place on a log axis, and the function drops such units rather than taking `log10(0)`. ## Fitting `marker_2` is the readout that falls as exposure rises, which is the shape a half-maximal *inhibitory* concentration describes. ```{r fit} fit <- cr_dose_response(exp, channel = "marker_2", model = "4pl", log_dose = TRUE) fit$model fit$params ``` `a` is the upper asymptote, `d` the lower, `e` the inflection point on the `log10(dose)` scale and `b` the slope there. ## IC50 / EC50 ```{r ic50} cr_ic50(fit) ``` The estimate is back-transformed to the dose scale, with the interval carried through from the standard error of `e`. ## Plotting ```{r plot-dr} cr_plot_dose_response(fit) ``` ## Comparing models Fixing the lower asymptote at zero moves the inflection point, and with it the IC50 — a reminder that the number is a property of the model as much as of the data: ```{r compare-models} fit_3pl <- cr_dose_response(exp, channel = "marker_2", model = "3pl") rbind( cbind(model = "4pl", cr_ic50(fit)), cbind(model = "3pl", cr_ic50(fit_3pl)) ) ``` ## Checking that the fit converged `cr_dose_response()` calls `stats::nls()` with `warnOnly = TRUE`, so a fit that runs out of iterations is **returned rather than raised**. Two checks catch it: ```{r converged} c(model = fit$model, any_na_se = anyNA(fit$params$std_error)) ``` * `fit$model` is `"linear"` instead of the model you asked for when `nls()` failed outright and the linear fallback took over. * `std_error` is `NA` for every parameter when `nls()` returned without converging. The point estimates from such a fit should not be quoted, and neither should the IC50 derived from them. A readout that *rises* with exposure is the usual cause. The logistic is fitted from starting values that describe a falling curve, so an increasing response often does not converge. Fit the channel that falls, or use the linear model: ```{r increasing} lin <- cr_dose_response(exp, channel = "marker_1", model = "linear") lin$params ``` Here the slope is the change in the unit-level median of `marker_1` per tenfold increase in exposure. ## Where this fits `vignette("end-to-end")` runs the full screening pipeline, in which the exposure axis is handled as a set of contrasts against the vehicle of each unit's own batch rather than as a fitted curve. Curve fitting is the right tool when the exposure levels are dense enough to define a shape; contrasts are the right tool when they are not.