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 or Inf.

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.

Info

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.
  • 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.

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])
Measurement errors

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.
Mutation of data

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])
Mutation of data

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)
Example block output

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)
Example block output

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])
Example block output

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])