Use this Skill for conjoint survey experiments: profile design, AMCE estimation via OLS, marginal means, interaction effects, and respondent-level heterogeneity.
Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.
Quelldateien prüfen
Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Ein direkter Befehl überspringt den Prüf-Prompt. Prüfen Sie die Quelle, bevor Sie ihn ausführen.
Der Befehl bleibt in einer Zeile. Scrollen Sie horizontal, um ihn vor dem Kopieren vollständig zu prüfen.
Sie bevorzugen eine lokale Kopie? Laden Sie die Dateien herunter, die SkillsMP derzeit vorliegen.
SKILL.md wird angezeigt
SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
conjoint-experiment
description
Use this Skill for conjoint survey experiments: profile design, AMCE estimation via OLS, marginal means, interaction effects, and respondent-level heterogeneity.
Design conjoint survey tasks (attributes, levels, profiles) with randomized full or partial
factorial profile generation
Estimate Average Marginal Component Effects (AMCEs) via linear probability model (OLS) with
standard errors clustered by respondent
Compute marginal means (unconditional on other attributes) as an alternative AMCE summary
Estimate interaction AMCEs to test whether attribute effects vary by other profile attributes
Analyze respondent-level heterogeneity in AMCEs (subgroup by party ID, ideology, demographics)
Detect inattentive respondents through straightlining, response time, and consistency checks
Power-analyze a conjoint study (tasks × respondents × attributes)
Conjoint experiments are used in political science to measure candidate evaluation, immigration
preferences, trade-off tolerance for policy packages, and partisan asymmetries.
Background
Conjoint design: Respondents see multiple tasks. In each task, they compare two (or more)
profiles that vary across predefined attributes. Each attribute has a set of levels. Levels are
randomly assigned to profiles independently across attributes (full factorial) or according to a
restricted design (partial factorial, to avoid implausible combinations).
AMCE (Average Marginal Component Effect): The AMCE for level l of attribute A is the
average difference in choice probability when A = l compared to a baseline level, averaging
over all possible randomizations of other attributes. Because profiles are randomized, the
estimator is simply OLS:
where i indexes tasks and j respondents. Standard errors clustered by respondent account
for within-respondent correlation across tasks.
Marginal means (Leeper, Hobolt & Tilley 2020) measure the expected choice probability when
an attribute takes a given level, averaged over all other attributes — without conditioning on a
reference level. This avoids interpretational dependence on the baseline choice.
Interaction AMCE: When an attribute's effect varies by another attribute (or a respondent
characteristic), the interaction is estimated by including the product term in the OLS model.
Subgroup AMCE: Splitting the sample by party ID (or ideology) and re-estimating AMCE per
subgroup tests partisan asymmetry in attribute preferences (common in immigration and candidate
evaluation conjoint studies).
Power: For a binary forced-choice conjoint, the minimum detectable effect (MDE) for an AMCE
depends on:
Number of tasks per respondent (T)
Number of respondents (N)
Number of levels per attribute (K) — more levels means each level appears less often
Rule of thumb (Bansak et al. 2021): N × T ≥ 5,000 tasks total for MDE ≈ 0.04.
No external data downloads required. Conjoint datasets are typically generated during survey
programming (Qualtrics, Formr) and exported as CSV. Store file path as:
import os
import numpy as np
import pandas as pd
import statsmodels.api as sm
import statsmodels.formula.api as smf
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import warnings
warnings.filterwarnings("ignore")
# ---------------------------------------------------------------------------# 1. Conjoint Profile Generator# ---------------------------------------------------------------------------defgenerate_conjoint_profiles(
attributes: dict[str, list],
n_respondents: int,
n_tasks: int = 5,
n_profiles: int = 2,
seed: int = 42,
) -> pd.DataFrame:
"""
Simulate a conjoint dataset with randomly assigned profile attributes.
Parameters
----------
attributes : dict
{attribute_name: [level1, level2, ...]}
Example: {'age': ['35', '45', '55'], 'gender': ['Man', 'Woman']}
n_respondents : int
Number of survey respondents.
n_tasks : int
Number of forced-choice tasks per respondent.
n_profiles : int
Number of profiles per task (typically 2 for forced-choice).
seed : int
Random seed for reproducibility.
Returns
-------
pd.DataFrame with one row per profile:
respondent_id, task_id, profile_id, chosen (0/1), + one column per attribute.
"""
rng = np.random.default_rng(seed)
attr_names = list(attributes.keys())
rows = []
for resp_id inrange(1, n_respondents + 1):
for task_id inrange(1, n_tasks + 1):
# Randomly assign levels to each profile
profile_levels = {}
for attr, levels in attributes.items():
profile_levels[attr] = rng.choice(levels, size=n_profiles, replace=True)
# Simulate choice based on hidden utility (for testing)# In real data, this comes from respondent answers
chosen_profile = rng.integers(0, n_profiles)
for prof_id inrange(n_profiles):
row = {
"respondent_id": resp_id,
"task_id": task_id,
"profile_id": prof_id + 1,
"chosen": int(prof_id == chosen_profile),
}
for attr in attr_names:
row[attr] = profile_levels[attr][prof_id]
rows.append(row)
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------# 2. AMCE Estimation via OLS# ---------------------------------------------------------------------------defestimate_amce(
df: pd.DataFrame,
outcome: str = "chosen",
attributes: list[str] | None = None,
reference_levels: dict[str, str] | None = None,
cluster_var: str = "respondent_id",
) -> pd.DataFrame:
"""
Estimate Average Marginal Component Effects (AMCE) via linear probability model.
Parameters
----------
df : pd.DataFrame
Long-format conjoint dataset (one row per profile).
outcome : str
Binary choice indicator (0/1).
attributes : list of str
Attribute column names. Inferred from reference_levels keys if not provided.
reference_levels : dict
{attribute: reference_level} — the level dropped (baseline).
If None, the first alphabetical level is used as baseline.
cluster_var : str
Variable for clustering standard errors (typically respondent_id).
Returns
-------
pd.DataFrame with AMCE estimates: attribute, level, amce, se, ci_lower, ci_upper, p.
"""
reference_levels = reference_levels or {}
attributes = attributes or [c for c in df.columns if c notin
[outcome, cluster_var, "task_id", "profile_id"]]
# Encode dummies for each attribute
dummy_frames = []
for attr in attributes:
ref = reference_levels.get(attr, sorted(df[attr].unique())[0])
dummies = pd.get_dummies(df[attr], prefix=attr, drop_first=False)
ref_col = f"{attr}_{ref}"if ref_col in dummies.columns:
dummies = dummies.drop(columns=[ref_col])
dummy_frames.append(dummies)
X_df = pd.concat(dummy_frames, axis=1).astype(float)
X = sm.add_constant(X_df.reset_index(drop=True))
y = df[outcome].reset_index(drop=True).astype(float)
groups = df[cluster_var].reset_index(drop=True)
model = sm.OLS(y, X)
result = model.fit(cov_type="cluster", cov_kwds={"groups": groups})
rows = []
for col in X_df.columns:
parts = col.split("_", 1)
attr = parts[0]
level = parts[1] iflen(parts) > 1else col
idx = col
if idx notin result.params.index:
continue
coef = result.params[idx]
se = result.bse[idx]
rows.append({
"attribute": attr,
"level": level,
"amce": round(coef, 5),
"se": round(se, 5),
"ci_lower": round(coef - 1.96 * se, 5),
"ci_upper": round(coef + 1.96 * se, 5),
"p": round(result.pvalues[idx], 4),
})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------# 3. Marginal Means# ---------------------------------------------------------------------------defmarginal_means(
df: pd.DataFrame,
outcome: str = "chosen",
attributes: list[str] | None = None,
weight_var: str | None = None,
) -> pd.DataFrame:
"""
Compute marginal means: weighted average choice probability per attribute level.
Parameters
----------
df : pd.DataFrame
outcome : str
attributes : list of str
weight_var : str, optional
Survey weight column.
Returns
-------
pd.DataFrame with attribute, level, marginal_mean, se, n.
"""
attributes = attributes or [c for c in df.columns if c notin
[outcome, weight_var or"__none__", "respondent_id",
"task_id", "profile_id"]]
rows = []
for attr in attributes:
for level, grp in df.groupby(attr):
y = grp[outcome].astype(float)
if weight_var and weight_var in grp.columns:
w = grp[weight_var].fillna(1.0)
mm = np.average(y, weights=w)
else:
mm = y.mean()
se = y.sem()
rows.append({
"attribute": attr, "level": str(level),
"marginal_mean": round(mm, 5),
"se": round(se, 5),
"n": len(grp),
})
return pd.DataFrame(rows)
# ---------------------------------------------------------------------------# 4. AMCE Coefficient Plot# ---------------------------------------------------------------------------defplot_amce(
amce_df: pd.DataFrame,
title: str = "AMCE Estimates",
figsize: tuple = (9, 7),
save_path: str | None = None,
) -> plt.Figure:
"""
Horizontal forest plot of AMCE estimates with 95% CI.
Parameters
----------
amce_df : pd.DataFrame
Output of estimate_amce().
title : str
figsize : tuple
save_path : str, optional
Returns
-------
matplotlib Figure.
"""
df_plot = amce_df.copy()
df_plot["label"] = df_plot["attribute"] + ": " + df_plot["level"]
df_plot = df_plot.sort_values(["attribute", "amce"], ascending=[True, False])
colors = plt.cm.tab10.colors
attr_list = df_plot["attribute"].unique().tolist()
attr_color = {attr: colors[i % 10] for i, attr inenumerate(attr_list)}
fig, ax = plt.subplots(figsize=figsize)
y_pos = range(len(df_plot))
row_colors = [attr_color[attr] for attr in df_plot["attribute"]]
ax.errorbar(
df_plot["amce"], list(y_pos),
xerr=[df_plot["amce"] - df_plot["ci_lower"], df_plot["ci_upper"] - df_plot["amce"]],
fmt="o", color="black", elinewidth=1.2, capsize=3, markersize=6, zorder=3
)
# Color-code by attribute
ax.scatter(df_plot["amce"], list(y_pos), color=row_colors, s=50, zorder=4)
ax.axvline(0, color="red", linestyle="--", linewidth=1, alpha=0.7)
ax.set_yticks(list(y_pos))
ax.set_yticklabels(df_plot["label"].tolist(), fontsize=9)
ax.set_xlabel("AMCE (change in Pr(chosen))")
ax.set_title(title, fontsize=13)
ax.grid(True, axis="x", alpha=0.3)
# Legend
patches = [mpatches.Patch(color=attr_color[a], label=a) for a in attr_list]
ax.legend(handles=patches, fontsize=8, loc="lower right")
plt.tight_layout()
if save_path:
fig.savefig(save_path, dpi=150)
return fig
# ---------------------------------------------------------------------------# 5. Subgroup AMCE Heterogeneity# ---------------------------------------------------------------------------defsubgroup_amce(
df: pd.DataFrame,
subgroup_var: str,
subgroup_values: list,
outcome: str = "chosen",
attributes: list[str] | None = None,
reference_levels: dict[str, str] | None = None,
cluster_var: str = "respondent_id",
) -> dict[str, pd.DataFrame]:
"""
Estimate AMCE separately for each subgroup value.
Parameters
----------
df : pd.DataFrame
subgroup_var : str
Column for splitting (e.g., 'party_id').
subgroup_values : list
Values to include (e.g., ['Democrat', 'Republican']).
Returns
-------
dict {subgroup_value: amce_df}
"""
results = {}
for val in subgroup_values:
sub = df[df[subgroup_var] == val].copy()
iflen(sub) < 50:
print(f"Warning: subgroup {val} has only {len(sub)} observations.")
continue
amce_df = estimate_amce(
sub, outcome=outcome, attributes=attributes,
reference_levels=reference_levels, cluster_var=cluster_var
)
results[val] = amce_df
return results
Advanced Usage
Inattentive Respondent Detection
import numpy as np
import pandas as pd
defdetect_inattentive_respondents(
df: pd.DataFrame,
respondent_col: str = "respondent_id",
chosen_col: str = "chosen",
time_col: str | None = None,
time_threshold_seconds: float = 5.0,
) -> pd.DataFrame:
"""
Flag respondents for inattention via straightlining detection.
Straightlining: always choosing the first (or second) profile across all tasks.
Fast responders: completing tasks faster than a minimum plausible threshold.
Parameters
----------
df : pd.DataFrame
respondent_col : str
chosen_col : str
time_col : str, optional
Column for task completion time in seconds.
time_threshold_seconds : float
Minimum plausible time per task.
Returns
-------
pd.DataFrame with respondent_id, n_tasks, always_chose_first, fast_responder, flag.
"""# Profile 1 corresponds to the first profile shown in each task# Assumes profile_id column exists
results = []
for resp_id, grp in df.groupby(respondent_col):
tasks = grp.groupby("task_id")
n_tasks = len(tasks)
choices_first = 0for tid, task in tasks:
first_profile = task["profile_id"].min()
chosen_profile_id = task.loc[task[chosen_col] == 1, "profile_id"]
ifnot chosen_profile_id.empty and chosen_profile_id.iloc[0] == first_profile:
choices_first += 1
always_first = (choices_first == n_tasks)
fast_resp = Falseif time_col and time_col in grp.columns:
avg_time = grp[time_col].mean()
fast_resp = avg_time < time_threshold_seconds
results.append({
"respondent_id": resp_id,
"n_tasks": n_tasks,
"pct_chose_first": round(choices_first / n_tasks, 3),
"always_chose_first": always_first,
"fast_responder": fast_resp,
"flag": always_first or fast_resp,
})
return pd.DataFrame(results)
defconjoint_power(
n_respondents: int,
n_tasks: int,
effect_size: float = 0.05,
alpha: float = 0.05,
) -> dict:
"""
Approximate power for conjoint AMCE detection.
Uses the normal approximation: power = Φ(|β| / SE - z_{α/2}).
SE ≈ 1 / sqrt(N * T / K) where K = 2 profiles per task (binary choice).
Parameters
----------
n_respondents : int
n_tasks : int
effect_size : float
AMCE to detect (e.g., 0.05 = 5 percentage points).
alpha : float
Significance level.
Returns
-------
dict with power, total_tasks, se_approx, mde.
"""from scipy.stats import norm
total_tasks = n_respondents * n_tasks
se_approx = 1 / np.sqrt(total_tasks / 2)
z_alpha = norm.ppf(1 - alpha / 2)
z_beta = abs(effect_size) / se_approx - z_alpha
power = norm.cdf(z_beta)
mde = z_alpha * se_approx * 2return {
"power": round(power, 4),
"total_tasks": total_tasks,
"se_approx": round(se_approx, 5),
"mde_90pct": round(mde * 1.28 / z_alpha, 5),
}
Troubleshooting
Problem
Cause
Solution
AMCE confidence intervals too wide
Too few tasks or respondents
Target ≥ 5,000 total profile observations; add tasks before respondents
Interaction AMCE is insignificant
Underpowered for interaction
Interactions require ~4× the observations; pre-specify if confirmatory
Marginal means sum to ≠ 0.5
Unequal number of levels per attribute
This is expected; MM interpretation is probability, not contrast
Respondent clustering ignored
Standard errors too small
Always use cov_type='cluster' with groups = respondent_id
Profile imbalance
Non-random level assignment in survey software
Check that each level appears ~equally often using df[attr].value_counts()
External Resources
Hainmueller, J., Hopkins, D. & Yamamoto, T. (2014). Causal inference in conjoint analysis.
Political Analysis, 22(1), 1-30.
Leeper, T.J., Hobolt, S.B. & Tilley, J. (2020). Measuring subgroup preferences in conjoint
experiments. Political Analysis, 28(2), 207-221.
Bansak, K. et al. (2021). Using conjoint experiments to analyze election outcomes.
Political Analysis, 29(3), 380-395.
Abramson, S.F., Kocak, K. & Magazinnik, A. (2022). What do we learn about voter preferences
from conjoint experiments? American Journal of Political Science.