Here's some R code to illustrate one way to handle this situation.
First, let's generate some toy data.
require(tidyverse)
require(broom)
# concentrations
x <- c(1, 1, 1, 3, 3, 3, 10, 10, 10, 30, 30, 30)
# toy parameters
M <- 1000
Bclean <- 20
Bdirty <- -20
SD <- 2
MATRIX_EFFECT <- 0.95
# assembling data into dataframe
set.seed(0)
clean <- M*x + Bclean + rnorm(mean = 0, sd = SD, n = length(x))
dirty <- (MATRIX_EFFECT)*M*x + Bdirty + rnorm(mean = 0, sd = SD, n = length(x))
df <-
tibble(x = x, clean = clean, dirty = dirty) %>%
pivot_longer(-x, names_to = 'matrix', values_to = 'response') %>%
arrange(matrix, x)
Here, x
is the independent variable, presumably a concentration. clean
and dirty
are names for two separate response vectors, one for a "clean" sample matrix and one for a "dirty" sample matrix.
(This way of creating the toy data assumes that you make authentic standards at the same concentrations when you make the calibration curves; but this isn't required for the statistical modeling that follows; the model will work if the two calibration curves both have totally different concentrations.)
I've assumed a value for M
, B
, SD
, and MATRIX_EFFECT
in this code, but to see how they would affect results I'd vary them and re-run the below code. The meaning of those parameters is:
M
is the slope or response coefficient for the "clean" sample matrix.
Bclean
is the intercept (or offset) for the "clean" sample matrix.
Bdirty
is the intercept for the "dirty" matrix.
SD
is the standard deviation (in response units) of both the clean and dirty responses. Here, I've assumed an unrealistically low value, which is helpful for being able to understand the meaning of the parameters returned by the fit.
MATRIX_EFFECT
is a fraction by which M
is modified in the "dirty" matrix relative to the clean matrix.
The result of this code is a "dataframe" that looks like this:
x matrix response
<dbl> <chr> <dbl>
1 clean 352.590857
1 clean 34.753328
1 clean 365.959853
3 clean 554.485864
3 clean 382.928287
3 clean -7.990008
10 clean 814.286593
10 clean 941.055911
10 clean 998.846565
30 clean 3480.930678
30 clean 3152.718692
30 clean 2840.198150
1 dirty -123.531402
1 dirty 48.107685
1 dirty 46.156976
3 dirty 235.697833
3 dirty 368.444690
3 dirty 139.615775
10 dirty 1147.136660
10 dirty 812.492316
10 dirty 1015.146423
30 dirty 3255.479129
30 dirty 3206.667272
30 dirty 3340.837902
We can model this data as follows:
df %>%
lm(data=., formula = response ~ x*matrix) %>%
tidy()
This is models the response as a linear (lm()
) function of a bunch of parameters, with parameters implicitly defined by the formula. response ~ x*matrix
means, since x
is a continuous variable and matrix
in this case is a factor variable, to regress response on concentration interacting with matrix. The results are:
Each row gives results, including standard errors, point estimates, and p-values that the parameter is non-zero, for a single parameter. One subtlety is that the algorithm has implicitly assumed one (of in this case two) levels of our factor variable matrix
to be the "baseline", and only parameters for the other non-baseline level (in this case "dirty") are referred to directly. So the results mean:
(Intercept)
is the model estimate of Bclean
. The estimate of 20.28 has an uncertainty of $\pm$ 0.740. Gratifyingly, the confidence interval overlaps the "true" Bclean
value we picked for this toy data above.
x
is the model estimate of M
matrixdirty
is the model estimate of Bclean - Bdirty
x:matrixdirty
is the model estimate of M*(1-MATRIX_EFFECT)
and is the parameter that you are really interested in. Dividing by M
, we see the regression says it is -49.96. Dividing by M, in this case estimated as 1000.02839, gives an estimate for the matrix effect of 0.95004. Thus, for these (made up) numbers, the model would say your dirty matrix has a calibration slope that is 4.996 percent of the clean matrix. Of course, you also have error estimates here. The std.error
of 0.0658 is also in response units; after dividing by M, we can see the model thinks it's matrix effect estimate is good to the third decimal place.
These amazing results are because I've picked an SD
of 2 for the simulated data, but the response varies between 100 and 30,000. So we're assuming relative error of 2% down to 0.006%. If you pick more realistic values for SD, the std.error
of all of the parameters will be much larger.