В этом сообщении показано, как создать эксперимент совместного опроса на основе выбора, который включает ограниченные комбинации атрибутов в JavaScript.

Что такое совместные эксперименты?

Совместные эксперименты широко используются в маркетинге и социальных науках для изучения отношения людей и принятия решений. Их большим преимуществом является то, что они позволяют аналитикам изучать, как люди оценивают некоторый интересующий объект и как эти оценки причинно зависят от конкретных атрибутов этого объекта. Например, исследователя рынка может интересовать, как потребители оценивают автомобиль на основе его топливной экономичности, трансмиссии, цены и ожидаемой стоимости при перепродаже, или политолога может интересовать, как избиратели оценивают кандидатов в президенты на основе их пол, уровень образования, профессия или возраст.

Типичный совместный эксперимент для изучения этих вопросов, например, как потребители выбирают автомобили, выглядел бы следующим образом: аналитик сначала должен определить ряд интересующих его атрибутов, а также «уровни» для каждого атрибута:

  • Цена: 10 000,- долларов США; 20 000,- долларов США; 50 000,- долларов США; 80 000,- долларов США;
  • миль на галлон: 10; 15; 20; 25;
  • Трансмиссия: механическая; автоматический;
  • Стоимость перепродажи: 5'000,- долларов США; 15 000,- долларов США; 50 000,- долларов США;

Затем они создадут опрос, в котором респондентам будут представлены два вымышленных автомобиля, каждый из которых имеет некоторую случайную комбинацию атрибутов,перечисленных выше:

Затем респондентов просили выбрать между двумя автомобилями и/или оценить каждый по какой-либо шкале (например, вероятность покупки). Альтернативным вариантом было бы показать респондентам только один автомобиль, который они должны оценить, или краткий текст («виньетка») с описанием автомобиля вместо таблицы.

Именно случайное сочетание атрибутов имеет решающее значение и делает конджойнты такими мощными, потому что это означает, что аналитики могут позже использовать данные из множества различных задач выбора для оценки беспристрастного причинно-следственного эффекта данного атрибута (например, ценник 50 '000,-) по решению людей, независимо от всех других атрибутов.

Однако во многих случаях существуют некоторые комбинации атрибутов, которые могут (кажутся) неправдоподобными и поэтому должны быть исключены. В текущем примере это будет автомобиль, цена которого составляет 10 000 или 20 000 долларов США, но ожидаемая стоимость перепродажи составляет 50 000 долларов США.

Создание совместных дизайнов с ограничениями в JavaScript

Мои коллеги и я в последнее время довольно много работали с совместными планами (или их родственными планами, дробными факторными виньетками), например, чтобы изучить, как люди будут распределять койки в отделении интенсивной терапии для пациентов с COVID-19. Но хотя мы сами взяли на себя почти весь процесс проектирования и анализа, мы полагались на внешних поставщиков и/или проприетарное программное обеспечение для реализации совместной задачи в наших опросах, что не идеально. Поэтому я хотел научиться самостоятельно проводить совместный эксперимент с помощью инструментов с открытым исходным кодом — в идеале с использованием JavaScript, потому что это позволило бы мне напрямую встроить эксперимент в анкету онлайн-опроса.

Одна отличная инструкция (на которую я здесь сильно опираюсь) помогла мне частично, но мне все еще нужно было настроить код примера в этой инструкции, чтобы можно было исключить определенные комбинации.

Далее я воспользуюсь своим кодом, чтобы воспроизвести совместный дизайн из исследования Скрытый американский консенсус об отношении к иммиграционной политике, проведенного Hainmueller et al.. используя JavaScript. Этот код, очевидно, может быть адаптирован для создания других дизайнов.

Шаг № 1 — определить массивы, содержащие все разные уровни атрибутов:

// Arrays containing all attribute levels:
var EducationArray = ["High school","No formal","Graduate degree","4th Grade","College degree","Two-year college","8th grade"]; 
var GenderArray = ["Male","Female"];
var OriginArray = ["Iraq","France","Sudan","Germany","Philippines","Poland","Mexico","Somalia","China","India"];
var Reason1Array = ["Seek better job","Escape persecution","Reunite with family"];
var Reason2Array = ["Seek better job","Reunite with family"];
var Job1Array = ["Nurse","Child care provider","Gardener","Construction worker","Teacher","Janitor","Waiter","Doctor","Financial analyst","Computer programmer","Research scientist"]; 
var Job2Array = ["Nurse","Child care provider","Gardener","Construction worker","Janitor","Waiter"]; 
var PlansArray = ["Contract with employer","Interviews with employer","Will look for work","No plans to look for work"];
var PriorArray = ["Once as tourist","Once w/o authorization","Never","Many times as tourist","Six months with family"];
var LanguageArray = ["Tried English, but unable","Used interpreter","Fluent English","Broken English"]
var ExpArray = ["None","1-2 years","3-5 years","5+ years"]

Обратите внимание, что существует две версии массивов Reason и Job. Это связано с тем, что эти два атрибута подлежат ограничениям: причиной иммиграции в США может быть только «бегство от преследования», если рассматриваемый иммигрант происходит из Ирака, Судана или Сомали, и их работа может быть только врачом, учителем, финансовым аналитик, программист или научный сотрудник, если у них есть хотя бы какое-то высшее образование.

Следующий шаг — создание функций, которые перемешивают (рандомизируют) атрибуты (это взято из инструкций Мэтью Грэма):

// Fisher -Yates shuffle:
function shuffle(array){
for (var i = array.length - 1; i > 0; i--){ 
  var j = Math.floor(Math.random() * (i + 1));
  var temp = array[i]; 
  array[i] = array[j];
  array[j] = temp; }
  return array; 
}

// Shuffle a vector, choose the first entry:
function shuffle_one(theArray){ 
    var out = shuffle(theArray);
    var out = out[0]; 
    return(out);
}

Теперь наступает важная часть: определение функции, которая строит пары профилей, исключая при этом определенные комбинации. Вот что делает следующий код:

// Builds profile pair & accounts for implausible combinations
//////////////////////////////////////////////////////////////
function genprof(){

// Profile "rump"
var sw1 = [1,shuffle_one(EducationArray),shuffle_one(GenderArray),shuffle_one(OriginArray),shuffle_one(ExpArray),shuffle_one(PlansArray),shuffle_one(PriorArray),shuffle_one(LanguageArray)];
var sw2 = [2,shuffle_one(EducationArray),shuffle_one(GenderArray),shuffle_one(OriginArray),shuffle_one(ExpArray),shuffle_one(PlansArray),shuffle_one(PriorArray),shuffle_one(LanguageArray)];

// Exclusion of reason, conditional on origin
if (sw1.includes("Iraq") || sw1.includes("Somalia") || sw1.includes("Sudan")) {
  sw1_end = [shuffle_one(Reason1Array)];
  sw1.push(sw1_end);
} else {
  sw1_end = [shuffle_one(Reason2Array)];
  sw1.push(sw1_end)
}
if (sw2.includes("Iraq") || sw2.includes("Somalia") || sw2.includes("Sudan")) {
  sw2_end = [shuffle_one(Reason1Array)];
  sw2.push(sw2_end);
} else {
  sw2_end = [shuffle_one(Reason2Array)];
  sw2.push(sw2_end)
};

// Exclusion of job, conditional on education
if (sw1.includes("No formal") || sw1.includes("4th grade") || sw1.includes("8th grade") || sw1.includes("High school")) {
  sw1_end = [shuffle_one(Job2Array)];
  sw1.push(sw1_end);
} else{
  sw1_end = [shuffle_one(Job1Array)];
  sw1.push(sw1_end);
};
if (sw2.includes("No formal") || sw2.includes("4th grade") || sw2.includes("8th grade") || sw2.includes("High school")) {
  sw2_end = [shuffle_one(Job2Array)];
  sw2.push(sw2_end);
} else{
  sw2_end = [shuffle_one(Job1Array)];
  sw2.push(sw2_end);
}

// Building profiles
var profiles = []
profiles.push(sw1);
profiles.push(sw2);
return(profiles);
};

Первая часть создает «багровый» профиль со всеми неограниченными атрибутами. После этого следуют два условия if-else, которые генерируют значения для атрибутов Job и Reason в зависимости от значений, которые были сгенерированы ранее. Последняя часть строит профили.

Я также хотел удостовериться, что два показанных профиля никогда не будут полностью идентичными (отдалённая возможность, но всё же…), поэтому я создал рекурсивную функцию, чтобы позаботиться об этом:

// checks for equality 
function checker(){
test = genprof();
if (test[0]==test[1]){
  console.log("Identical profiles, doing over!");
  test = genprof();
  checker();
  return(test);
} else {
  return(test);
}
};

Наконец, в последней части задается количество создаваемых задач выбора (я установил это значение около 13 000, что аналогично данным Хайнмюллера и др.), а затем определяется цикл для создания профилей и объединения их в общий множество. Затем этот массив «выравнивается» для более удобной обработки в дальнейшем.

// Number of choice tasks
var ncomps = 13080;

// combine profiles to "deck" seen by individual respondent (necessary when using this code within an online survey)
deck = []; // captures profile deck
for (let i = 0; i <= ncomps; i++){
x = checker();
deck.push(x);
};

// flatten array to "table"
deck = deck.flat(); 

Пока что этот код можно использовать для создания и заполнения объединенного дизайна в онлайн-вопроснике «на лету» (количество заданий на выбор тогда, конечно, будет сокращено до чего-то более удобного для одного респондента, скажем, до 5).

В этом случае я хотел создать целую «базу данных» профилей, чтобы увидеть, успешно ли написанный мной код генерирует такое же распределение атрибутов, как Hainmueller et al. дизайн. (Экспорт проекта иногда также необходим, когда он должен быть передан поставщикам платформ для опросов.)

Следующий код (на основе этого поста в stackoverflow) делает это:

// EXPORT TO CSV 
////////////////

// adds dim names
deck.unshift(["ProfileNo","Education","Gender","Origin","Experience","Plans","Prior","Language","Reason","Job"]); 

    
// Convert to CSV string
function arrayToCsv(data){
  return data.map(row =>
    row
    .map(String)  // convert every value to String
    .map(v => v.replaceAll('"', '""'))  // escape double colons
    .map(v => `"${v}"`)  // quote it
    .join(',')  // comma-separated
  ).join('\r\n');  // rows starting on new lines
};  
file = arrayToCsv(deck);

// Download contents as a file
function downloadBlob(content, filename, contentType) {
  // Create a blob
  var blob = new Blob([content], { type: contentType });
  var url = URL.createObjectURL(blob);

  // Create a link to download it
  var pom = document.createElement('a');
  pom.href = url;
  pom.setAttribute('download', filename);
  pom.click();
};
downloadBlob(file, 'cjoint_profiles.csv', 'text/csv;charset=utf-8;');

Весь файл кода JavaScript (здесь он называется cjoint_gen.js) теперь можно встроить в простой файл HTML, который можно запустить в любом браузере для автоматического создания и экспорта профилей в виде файла CSV:

<!DOCTYPE html>
<html>
  <head><title>Generate conjoint profiles</title></head>
  <body>
    <div id="test">Your profiles have been created; please check your Downloads folder for a CSV file called "cjoint_profiles.csv".</div>
    <script src="cjoint_gen.js"></script>
  </body>
</html>

Это работает?

Я создал пакет профилей, а затем использовал R, чтобы проверить, работает ли дизайн так, как должен. Чтение данных, очевидно, является первым шагом:

library(tidyverse)

profiles <- read.csv("cjoint_profiles.csv")

profiles %>% 
  mutate(across(Education:Language, as.factor)) -> profiles

Основываясь на выбранном дизайне (и предыдущих результатах), должно быть меньше профилей, у которых в качестве причины миграции указано Спасение от преследований, и меньше профилей с высококвалифицированной работой — потому что эти атрибуты могли появиться только при наличии некоторых других атрибутов. . Чтобы увидеть, так ли это, я создал простой (и не очень отточенный) график, показывающий распределение каждого атрибута во всей выборке профилей:

as.data.frame(unlist(apply(profiles, 2, function(x){
  prop.table(table(x))*100
}))) %>% 
  rename(perc = starts_with("unlist")) %>% 
  rownames_to_column(var = "attribute") %>% 
  ggplot(aes(y = attribute, x = perc)) +
  geom_bar(stat = "identity") +
  labs(x = "Percent", y = "") +
  theme_classic()

Результат выглядит следующим образом:

Профилей, стремящихся избежать преследований или обладающих высококвалифицированными профессиями, действительно меньше.

Вторая важная вещь, которую нужно проверить, это, конечно, сработала ли рандомизация — действительно ли атрибуты независимы друг от друга (за исключением тех, которые ограничены). Чтобы проверить это, я оцениваю критерии хи-квадрат для каждой пары атрибутов и визуализирую полученные значения p-:

profiles %>% 
  select(-ProfileNo) -> profiles2

as.data.frame(apply(profiles2,2,function(x){
  apply(profiles2,2,function(y){
  res <- chisq.test(table(y,x))
  return(res$p.value)
})
  })) %>% 
  rownames_to_column(var = "dimension1") %>% 
  pivot_longer(cols = -starts_with("dimension"),
               values_to = "pval",
               names_to = "dimension2") %>% 
  as.data.frame(rando) %>% 
  ggplot(aes(x = dimension1, y = dimension2, fill = pval)) +
    geom_tile() +
    geom_text(aes(label = format.pval(pval, eps = 0.01, digits = 2))) +
  scale_fill_gradient(low="red", high="white") +
    labs(x = "", y = "",
         fill = "p-value") +
    theme_classic() +
    theme(legend.position = "bottom")

Результат обнадеживает: почти только те пары атрибутов, которые имеют исключенные комбинации, связаны друг с другом, но Причина и Язык сближаются, и между существует значительная связь. Планы и Язык.

Наличие неожиданных ассоциаций между атрибутами, которые должны быть некоррелированными, не является большой проблемой. Во-первых, это могут быть просто ложные срабатывания (что и следовало ожидать при выполнении статистических тестов 9x9=81), и, для сравнения, оригинальный Hainmueller et al. данные (полученные из AJPS dataverse) также содержат некоторые ассоциации (которые также могут быть ложными срабатываниями):

hain <- labelled::unlabelled(haven::read_dta("immigrant.dta"))

hain %>% 
  select(starts_with("Feat")) -> profiles

as.data.frame(apply(profiles,2,function(x){
  apply(profiles,2,function(y){
    res <- chisq.test(table(y,x))
    return(res$p.value)
  })
})) %>% 
  rownames_to_column(var = "dimension1") %>% 
  pivot_longer(cols = -starts_with("dimension"),
               values_to = "pval",
               names_to = "dimension2") %>% 
  as.data.frame(rando) %>% 
  ggplot(aes(x = dimension1, y = dimension2, fill = pval)) +
  geom_tile() +
  geom_text(aes(label = format.pval(pval, eps = 0.01, digits = 2))) +
  scale_fill_gradient(low="red", high="white") +
  labs(x = "", y = "",
       fill = "p-value") +
  theme_classic() +
  theme(legend.position = "bottom")