Measurement data
To work with measurement data, it needs to be stored in a FittingData
object from FittingObjectiveFunctions.jl
.
using FittingObjectiveFunctions
fitting_data = FittingData(concentrations, mean_responses, uncertainties)
Check data
Good dose-response data must satisfy the following properties:
- The concentrations and the responses must be positive.
- All values must be real numbers.
- No
NaN
orInf
.
These properties can be checked, using dose_response_check
:
julia> dose_response_check(FittingData([1,2],[-1,2]))
ERROR: DomainError with -1: Numbers must not be negative. Got -1 at index 1.
If dose_response_check
does not throw an error, the FittingData
object contains proper dose-response data.
dose_response_check
is used internally by some functions to ensure proper data transformation. E.g. data normalization or depletion corrections require proper dose-response data.
Normalize data
Dose-response curves from different sources may require normalization. For example, replicates from different experiments could have used different exposure times. There are two methods to normalize the data. The first method requires a reference signal (scanned during the experiments) to which the other responses can be normalized. The second method normalizes the dose-response curve s.t. the strongest response is 1
.
To normalize the data to 1
use the normalize_data
function without additional keywords.
data = FittingData([1,2,3],[1,2,3])
normalize_data(data)
FittingData([1, 2, 3], [0.3333333333333333, 0.6666666666666666, 1.0], [0.3333333333333333, 0.3333333333333333, 0.3333333333333333], Function[FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution])
To normalize the data to a reference signal, i.e. 2
, the reference
keyword can be used:
normalize_data(data, reference = 2)
FittingData([1, 2, 3], [0.5, 1.0, 1.5], [0.5, 0.5, 0.5], Function[FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution])
Another common normalization step is the removal of a constant offset, e.g. caused by autofluorescence. Since both offset removal and reference scaling are applied, it is important to understand the order of the corrections:
The offset is subtracted from all response values
- If the
reference
keyword is used, the offset is also subtracted from the reference value. This is, because reference measurements often suffer from the same offset.
- If the
The responses are rescaled
- If the
reference
keyword was used, all responses are divided by the modified reference value. - Otherwise, the responses are rescaled, s.t. the largest response is
1
.
- If the
In the following example, 0.5
is subtracted from the response values [1,2,3]->[0.5,1.5,2.5]
. Then the responses are divided by the largest response 2.5
to rescale the responses:
normalize_data(data, offset = 0.5)
FittingData([1, 2, 3], [0.2, 0.6, 1.0], [0.4, 0.4, 0.4], Function[FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution])
The measurement errors are also normalized, following standard Gaussian error propagation (reference
and offset
are without errors). Thus, the measurement errors are just rescaled.
Without the reference
keyword, it is possible to have the offset value be determined automatically, i.e. the dose-response curve is shifted s.t. the smallest response is zero:
normalize_data(data, offset = Inf)
Internally, any offset that is too large is replaced by the smallest response value. When the reference
keyword is used, it is not recommended to use arbitrary offsets, as they are subtracted from the reference value, too. Furthermore, offset values that are larger than the reference value (after being replaced by the smallest response) throw an error:
normalize_data(data, offset = 2, reference = 0.1)
ERROR: DomainError with 1:
The offset value must be smaller than the reference value.
normalize_data
does not mutate the original FittingData
object, but returns a normalized copy. To mutate the original object, use normalize_data!
.
Simple depletion correction
To keep the models computationally simple, e.g. by using analytical solutions, antibody depletion is not accounted for. Yet, depending on the experimental protocol, depletion can be unavoidable. Fortunately, there is simple approximation to obtain new concentrations that define lower bounds for the real binding process that is subject to depletion:
Let $\{(a_i,r_i)\}$ be data points where, $a_i$ denotes the initial antibody concentrations and $r_i$ denotes the corresponding responses. Then lower-bound concentrations are
\[b_i = a_i - \widehat{\beta} r_i \qquad \text{where} \qquad \widehat{\beta} = \max \{\beta \in \mathbb{R}_0 \mid a_i -\beta r_i \geq 0 \ \forall i\}\]
This approximation holds true both for the accumulation model as well as the Langmuir isotherm model, albeit for different reasons (cf Edwards et al and https://arxiv.org/abs/2407.06052). The maximization of the parameter $\widehat{\beta}$ is necessary to obtain a worst-case scenario if the actual proportionality factor between the response signal and the actual number of bound complexes is unknown.
To obtain this "scale bound" $\widehat{\beta}$ the scale_bound
function can be used:
β = scale_bound(FittingData([1,2,3],[1,1.5,1.9]))
1.0
To obtain the lower-bound concentrations $b_i$, the simple_depletion_correction
function can be used:
simple_depletion_correction(FittingData([1,2,3],[1,1.5,1.9]), β)
FittingData([0.0, 0.5, 1.1], [1.0, 1.5, 1.9], [1.0, 1.0, 1.0], Function[FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution])
Note that a new FittingData
object is returned, containing the lower-bound concentrations b_i
instead of the initial concentrations a_i
. It is possible to use different scales for simple_depletion_correction
. But for the scale_bound
there is a shortcut (which internally calls scale_bound
):
simple_depletion_correction(FittingData([1,2,3],[1,1.5,1.9]))
FittingData([0.0, 0.5, 1.1], [1.0, 1.5, 1.9], [1.0, 1.0, 1.0], Function[FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution])
Again, simple_depletion_correction
does not mutate the original FittingData
object, but returns a corrected copy. To mutate the original object, use simple_depletion_correction!
.
Plotting
Of course, FittingData
objects can be plotted with any plotting library by calling the fields:
using Plots
data = FittingData([0,1,2,3,4],[0,1,1.5,1.9,2.1])
plot(data.independent, data.dependent, yerrors = data.errors)
For convenience, AntibodyMethodsDoseResponseRecipes.jl
contains plotting recipes for FittingData
objects:
using Plots, AntibodyMethodsDoseResponseRecipes
data = FittingData([0,1,2,3,4],[0,1,1.5,1.9,2.1])
plot(data)
Observe that the data point with concentration = 0
is missing. Since dose-response curves are commonly plotted in a logarithmic scale and since Plots.jl
does not automatically remove zero values from logarithmic plots (which become $-\infty$), the plotting recipe introduces the keyword filter_zeros = [true,false]
. The filter_zeros
keyword expects two Bool
values. If the first value is true
, all data points with concentration = 0
are removed from the plot. Accordingly, if the second value is true
, all data points with response = 0
are removed from the plot.
The plotting recipe defines how Plots.jl
handles the data inside a FittingData
object. Thus, the usual keywords remain usable and can be combined with the filter_zeros
keyword:
plot(data, color = :red, filter_zeros = [false,false])
FittingData
and FittingCondition
Internally FittingCondition
objects expect measurement data to be stored in FittingData
objects. Yet, the quick start guide mentions FittingData
objects only as optional method to implement specific measurement errors. This is, because FittingCondition
offers constructors that automatically create the FittingData
objects.
The default constructor uses FittingData
objects:
data = FittingData([1,2,3],[1,1.5,1.9], [0.4,0.4,0.4], distributions = (y,m,Δy)-> -abs(y-m))
condition = FittingCondition(data)
condition.data
FittingData([1, 2, 3], [1.0, 1.5, 1.9], [0.4, 0.4, 0.4], Function[Main.var"#1#2"(), Main.var"#1#2"(), Main.var"#1#2"()])
Using the FittingData
constructor allows to specify different measurement errors, as mentioned in the tip, but also different uncertainty distributions for posterior based objectives.
The convenience constructor bypasses the need to construct the FittingData
explicitly, expecting only the concentrations and the responses:
condition = FittingCondition([1,2,3],[1,1.5,1.9])
condition.data
FittingData([1, 2, 3], [1.0, 1.5, 1.9], [1.0, 1.0, 1.0], Function[AntibodyMethodsDoseResponseConvenience.var"#19#26"(), AntibodyMethodsDoseResponseConvenience.var"#19#26"(), AntibodyMethodsDoseResponseConvenience.var"#19#26"()])
Note that the measurement errors are set to 1
.
Finally, the convenience constructor allows to pass multiple response arrays:
condition = FittingCondition([1,2,3],[1,1.5,1.9],[0.8,1.7,2.8])
condition.data
FittingData([1, 2, 3], [0.9, 1.6, 2.3499999999999996], [0.14142135623730948, 0.14142135623730948, 0.6363961030678927], Function[AntibodyMethodsDoseResponseConvenience.var"#25#32"(), AntibodyMethodsDoseResponseConvenience.var"#25#32"(), AntibodyMethodsDoseResponseConvenience.var"#25#32"()])
If multiple response arrays are provided to the constructor, it calculates the mean values for the responses and uses the standard deviation for the measurement errors. In addition, it adds the individual responses as replicates
:
condition.replicates
2-element Vector{FittingData}:
FittingData([1, 2, 3], [1.0, 1.5, 1.9], [1.0, 1.0, 1.0], Function[FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution])
FittingData([1, 2, 3], [0.8, 1.7, 2.8], [1.0, 1.0, 1.0], Function[FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution, FittingObjectiveFunctions.normal_distribution])