Search code examples
rif-statementrankingqualtrics

How to grade a rank order quiz to allow for partial credit in R


Right now I have a data set with 23 quiz questions, where respondents had to put named steps in the correct order. I used Qualtrics, so the data was output in a way where it tells me where respondents put each step in the list of 23. Here's a condensed version of the data

ResponseID  | Step1| Step2 |Step3|.....|Step22|Step23
p1          | 1    | 2     | 3   |.....|22     |23 
p2          | 2    | 3     | 4   |.....|23     |1 
p3          | 2    | 23    | 7   |.....|12     |17 

The correct order is numerical. So anyone who had a row of their answers going from 1 to 23, in order, would have gotten all the steps in the correct order.

Oringally, I graded it by assigning point values like so:

dissdata <- dissdata %>%
  mutate(retention1score = ifelse(dissdata$Q9.2_1 == 1, 1, 0) +
                          ifelse(dissdata$Q9.2_2 == 2, 1, 0) +
                          ifelse(dissdata$Q9.2_3 == 3, 1, 0) +
                          ifelse(dissdata$Q9.2_4 == 4, 1, 0) +
                          ifelse(dissdata$Q9.2_5 == 5, 1, 0) +
                          ifelse(dissdata$Q9.2_6 == 6, 1, 0).................
                          ifelse(dissdata$Q9.2_23 == 23, 1, 0))

So based off of this, respondent p1 in the table above got a score of 100% or 23/23. Respondents p2 and p3 got 0 points. But as you can see, respondent 2 was slightly more correct than respondent 3. Respondent p2 was one-off for the whole quiz, whereas respondent3 was entirely wrong.

So, this if else method works for really strict, all or nothing grading. But it doesn't account for people who may have been 1 step off, like putting step number 1 as step number 2. I want to be able to assign partial credit for being 1 2 or 3 steps off. Or for anyone who may have had chunks of it correct.

How can I accomplish this in R? This link seems to be a great solution if anyone can guess at how they accomplished it: Programmatic Partial Credit Put In Order Grading

Thanks!


Solution

  • "Distance"

    One might consider a distance-function, where "perfectly right" has a distance of zero, and anything else is less than 100%. There is a lot of wiggle room in interpreting this method, but any partial credit instead of "WRONG! 0%" can be considered a more consoling teaching method.

    quux <- structure(list(ResponseID = c("p1", "p2", "p3"), Step1 = c(1L, 2L, 2L), Step2 = c(2L, 3L, 23L), Step3 = c(3L, 4L, 7L), Step22 = c(22L, 23L, 12L), Step23 = c(23L, 1L, 17L)), class = "data.frame", row.names = c(NA, -3L))
    quux
    #   ResponseID Step1 Step2 Step3 Step22 Step23
    # 1         p1     1     2     3     22     23
    # 2         p2     2     3     4     23      1
    # 3         p3     2    23     7     12     17
    
    # you should probably use 1:23, but I only have a subset of data
    correct <- c(1, 2, 3, 22, 23)
    

    This first distance function heavily penalizes (for instance) Step23 for respondent 2, since the absolute difference is 23 - 1 = 22. The math is simply Pythagorean on n-dim data. I'll use quux[,-1] to exclude the ResponseID column.

    do.call(mapply, c(list(FUN = function(...) sqrt(sum((unlist(list(...)) - correct)^2))), quux[,-1]))
    # [1]  0.00000 22.09072 24.37212
    

    Here, 0.000 is clearly 100%, and the others are various levels of "not right". The worst in this 5-question case is just above 45.7, where Step1=23, Step2=22, ..., Step23=1; If all 23 are perfectly backwards, then the penalty score here is

    sqrt(sum((1:23 - 23:1)^2))
    # [1] 63.62389
    

    Over to you if you think 22.09072 is worth a linear partial, as in

    100 * (63.62389 - do.call(mapply, c(list(FUN = function(...) sqrt(sum((unlist(list(...)) - correct)^2))), quux[,-1]))) / 63.62389
    # [1] 100.00000  65.27920  61.69345
    ### percent
    

    That is hasty and should really be thought-through before accepting carte blanche.

    Another thought is that you take the presence of right/wrong, not the distance. That is, Step23=1 is valued 1, as is Step23=22 valued 1, but correct values are valued 0.

    do.call(mapply, c(list(FUN = function(...) sqrt(sum(unlist(list(...)) != correct))), quux[,-1]))
    # [1] 0.000000 2.236068 2.236068
    

    Note that we don't need ^2 here, since it's always 0 or 1, so the square does nothing. (Feel free to keep it if it makes you feel better about the Pythagorean nature of this method ...)

    We could just as easily do == instead of !=, and the values would be 2.236 for 100%, but I thought I'd be consistent with the inversion started in the first part.

    The perfectly-wrong (all 23 are off by 1 or more) is merely

    sqrt(sum(2:24 != 1:23))
    # [1] 4.795832
    

    "Longest Sequence"

    The article you linked leans towards "longest correctly-ordered chain" from the numbers. If you look at https://www.r-bloggers.com/2014/09/compute-longest-increasingdecreasing-subsequence-using-rcpp/, that's a method that finds the longest chain in the sequence of numbers.

    Using their function:

    longest_subseq <- function(x) {
        P = integer(length(x))
        M = integer(length(x) + 1)
        L = newL = 0
        for (i in seq_along(x) - 1) {
            lo = 1
            hi = L
            while (lo <= hi) {
                mid = (lo + hi)%/%2
                if (x[M[mid + 1] + 1] < x[i + 1]) {
                    lo = mid + 1
                } else {
                    hi = mid - 1
                }
            }
            newL = lo
            P[i + 1] = M[newL]
            if (newL > L) {
                M[newL + 1] = i
                L = newL
            } else if (x[i + 1] < x[M[newL + 1] + 1]) {
                M[newL + 1] = i
            }
        }
        k = M[L + 1]
        re = integer(L)
        for (i in L:1) {
            re[i] = k + 1
            k = P[k + 1]
        }
        re
    }
    

    We can then find how many of the numbers (in each row) are in the correct order:

    longest_subseq(c(1, 2, 3, 22, 23))
    # [1] 1 2 3 4 5  ## length 5, perfect score!
    longest_subseq(c(2, 3, 4, 23, 1))
    # [1] 1 2 3 4    ## length 4
    longest_subseq(c(2, 23, 7, 12, 17))
    # [1] 1 3 4 5    ## length 4
    

    and automate it as

    do.call(mapply, c(list(FUN = function(...) length(longest_subseq(unlist(list(...))))), quux[,-1]))
    # [1] 5 4 4
    

    In this case, respondent 1 had all five (of these) in the correct order; respondents 2 and 3 only had one out of the preferred relative order.

    This method provides a much clearer scoring: all 23 in order is 100%