library(tidyverse)
## ── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
## ✔ dplyr 1.2.1 ✔ readr 2.2.0
## ✔ forcats 1.0.1 ✔ stringr 1.6.0
## ✔ ggplot2 4.0.3 ✔ tibble 3.3.1
## ✔ lubridate 1.9.5 ✔ tidyr 1.3.2
## ✔ purrr 1.2.2
## ── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
## ✖ dplyr::filter() masks stats::filter()
## ✖ dplyr::lag() masks stats::lag()
## ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(ggthemes)
PF16 <- read_tsv("data.csv")
## Rows: 49159 Columns: 169
## ── Column specification ────────────────────────────────────────────────────────
## Delimiter: "\t"
## chr (1): country
## dbl (168): A1, A2, A3, A4, A5, A6, A7, A8, A9, A10, B1, B2, B3, B4, B5, B6, ...
##
## ℹ Use `spec()` to retrieve the full column specification for this data.
## ℹ Specify the column types or set `show_col_types = FALSE` to quiet this message.
According to documentation from the website, they removed anyone under the age of 13 from the downloadable set, as well as anyone who didn't want their data included. But there are still children in the set (13 to 17), and also our immortal beings (i.e., 2 billion years old). We definitely want to remove those rows from data, since a random answer to the age question could also mean random answers on the rest of the measure. So let's get some descriptive data on our age measure, and make decisions about who to keep and who to filter out.PF16 %>%
filter(age > 99) %>%
group_by(age) %>%
summarise(count = n())
## # A tibble: 63 × 2
## age count
## <dbl> <int>
## 1 100 5
## 2 101 2
## 3 102 1
## 4 118 1
## 5 141 1
## 6 147 1
## 7 158 1
## 8 181 1
## 9 188 1
## 10 193 1
## # ℹ 53 more rows
I did some Googling, and the oldest person to ever live (recorded) was 122 years, and the oldest person still living is 117. I’m inclined to say anyone over 102 is not being truthful, so we’ll plan on filtering them out. Likely many people were not being truthful about age, but unless we have direct evidence they're lying, we'll have to leave them in. With the remaining respondents, we’ll classify their ages into groups: 13 to 17, 18 to 24, 25 to 34, 35 to 44, 45 to 64, 65+. I’m also going to add IDs to the file. Since each row is a person, I’ll just use row numbers.
PF16 <- PF16 %>%
filter(age <= 102) %>%
mutate(ID = row_number(),
age_group = cut(age,
breaks = c(12, 17, 24, 34, 44, 64, Inf),
labels = c("13 to 17", "18 to 24", "25 to 34", "35 to 44", "45 to 64", "65+"),
right = TRUE))
To create the age grouping variable, I used the cut function, where I define my break points and use right = TRUE to say that the value provided is the end of each break. The first value in the "right = TRUE" notation should be the starting value minus 1.PF16 <- PF16 %>%
mutate(across(A1:P10, ~na_if(., 0)))
Now we want to start identifying people with response patterns that won't provide meaningful information. This would include people who responded carelessly - like selecting the same option for each item or going between two options (like 4-5-4-5) - or with too much missing data. However, we want to be conservative in who we remove. Presumably, this was a volunteer survey that people did out of interest or curiosity. In those cases, some careless responding may mean the item was confusing, doesn't measure what it's supposed to, etc. But that doesn't mean there might not be some captive audience respondents - people who were required to complete the measure as part of coursework or a group activity - that just wanted to get it over with. Without being able to measure or control the context, we just don't know. This is why I'd like to be conservative in who I remove, because I'd rather retain people who were truly being careless than risk removing people whose "careless" responses are actually due to problematic items or scales.library(careless)
Items <- PF16 %>%
select(A1:P10)
longstrings <- longstring(Items, avg = TRUE)
Flags <- tibble(
ID = PF16$ID,
longstring = longstrings$longstr,
avgstring = longstrings$avgstr,
irv = irv(Items, na.rm = TRUE),
na_count = rowSums(is.na(Items)),
pct_long = longstring/163
)
Let's start with IRV, which should help us identify the worst offenders in terms of careless responding. Lower IRVs (say, less than 0.50) would identify people who gave the same answer or one of two answers to every item. Let's visualize our irv variable before we make any decisions on what to filter out.
library(ggthemes)
Flags %>%
ggplot(aes(irv)) +
geom_histogram() +
theme_economist_white()
## `stat_bin()` using `bins = 30`. Pick better value `binwidth`.
## Warning: Removed 171 rows containing non-finite outside the scale range
## (`stat_bin()`).
We have a number of cases with an IRV of 0. Those are our true straight-liners. There are also cases with IRV of 0.50 or less. Those would be the people who bounce between two options. The overall counts are low, so we won't lose too many cases by filtering out any with an IRV of 0.50 or less.
Flags <- Flags %>%
filter(irv > 0.50)
This dropped 381 cases. Now let's look at longstrings. Our IRV filter dropped people who showed careless response patterns across the whole measure, but we likely still have careless responders here. We just need to be judicious and data-driven in how we identify them.
range(Flags$pct_long)
## [1] 0.006134969 0.441717791
I also ran a basic histogram much like the one above for IRV (not pictured), and counts above 10% were so low, they weren't even visible. I reran the histogram, this time specifying bin width to figure out an exact cutoff, and limiting the scale to values between 0 and 50% (since we know our longest string is 44%).
Flags %>%
ggplot(aes(pct_long)) +
geom_histogram(binwidth = .01) +
scale_x_continuous(limits = c(0, 0.50)) +
theme_economist_white()
We have a large dropoff around the 9% string percentage. Let’s see how many cases we’d lose with that cutoff, as well as 10%.
Flags %>%
filter(pct_long >= 0.09)
## # A tibble: 335 × 6
## ID longstring avgstring irv na_count pct_long
## <int> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 42 15 1.90 0.553 0 0.0920
## 2 56 17 1.75 0.860 0 0.104
## 3 176 15 1.96 0.769 1 0.0920
## 4 553 17 2.51 0.650 0 0.104
## 5 617 17 2.51 0.737 11 0.104
## 6 2038 17 3.40 0.688 0 0.104
## 7 3418 18 2.09 1.31 0 0.110
## 8 3995 15 1.47 1.30 1 0.0920
## 9 4035 15 2.86 0.985 0 0.0920
## 10 4385 16 2.12 0.849 0 0.0982
## # ℹ 325 more rows
Flags %>%
filter(pct_long >= 0.10)
## # A tibble: 265 × 6
## ID longstring avgstring irv na_count pct_long
## <int> <dbl> <dbl> <dbl> <dbl> <dbl>
## 1 56 17 1.75 0.860 0 0.104
## 2 553 17 2.51 0.650 0 0.104
## 3 617 17 2.51 0.737 11 0.104
## 4 2038 17 3.40 0.688 0 0.104
## 5 3418 18 2.09 1.31 0 0.110
## 6 6013 19 1.68 0.805 0 0.117
## 7 6909 21 1.92 1.51 1 0.129
## 8 7120 17 2.76 0.574 2 0.104
## 9 7158 18 1.99 0.718 6 0.110
## 10 7686 18 2.04 0.706 0 0.110
## # ℹ 255 more rows
A cutoff of 9% translate to a longest string of 15, and 10% translates to 17. Most of our scales contain 10 items, with the exception being scale B, intellect, at 13 items. Further, each scale contains 3-5 negatively worded items. If those items and scales are functioning as expected, the longest possible string would be 8. But what if those items and scales aren't functioning as expected? What if a negative item, that test developers thought represented the opposite of the trait being measured, is completely unrelated?At the same time, these groups make up a small proportion of the overall sample. Using a cutoff of 10%, we would only lose about 0.5% of our overall total. So let's use 10%.
Flags <- Flags %>%
filter(pct_long < 0.1)
NAs <- Flags %>%
group_by(na_count) %>%
summarise(count = n())
head(NAs)
## # A tibble: 6 × 2
## na_count count
## <dbl> <int>
## 1 0 35127
## 2 1 8705
## 3 2 2595
## 4 3 903
## 5 4 423
## 6 5 158
If we kept only complete cases, we’d still have over 35,000 cases to
work with. But our psychometric approaches (CTT and Rasch) can handle
some (but not a lot of) missing data. Using a cutoff of 2 missing or fewer would let us keep most of our sample, and shouldn't impact our ability to validate these scales.
Flags <- Flags %>%
filter(na_count <= 2)
Now that we have our sample of people who appear to have put some
effort into responding to the items, we can filter our PF16 dataset to
just include those individuals.
PF16 <- PF16 %>%
filter(ID %in% Flags$ID)
And to make it easier on myself, I'll save this dataset, so that I can start with it in my next post, where we'll work on CTT psychometric analysis!
write_csv(PF16, "Cleaned PF16 Data.csv")
See you in my next post!


No comments:
Post a Comment