Show R code
source("R/fast-tech-shared.R")
figure_caption <- function(title, reading_key) {
paste0(
title,
"<br><span class='figure-key'><strong>How to read:</strong> ",
reading_key,
"</span>"
)
}WEEE Forum Fast-Tech Consumer Study
Behavioural analyses of fast tech purchasing, ownership and end-of-life decisions.
21 September 2026
This report examines the factors associated with fast tech purchasing, willingness, future intentions and end-of-life methods.
Factors influencing buying
Are buying attitudes, perceived social approval, dynamic norms, durability perceptions and socio-demographic characteristics associated with having bought fast tech?
Durability and future choices
Which behavioural factors distinguish willingness to pay for longer-lasting alternatives, replacement choices and intentions to increase, reduce or stop future fast-tech purchases?
Exposure and market context
How do exposure to fast tech, ownership and recent purchasing relate to one another?
End-of-life pathways
How closely does disposal follow purchasing volume, and which attitudes, norms, lifespan perceptions and declared motivations distinguish keeping, recycling and inappropriate waste disposal?
Communication and policy levers
Which intersections and respondent profiles offer the clearest opportunities for durability information, purchase-friction messages, take-back communication, collection design and regulatory action?
model_country_levels <- sort(
unique(as.character(df$Country))
)
sociodemo_cross_data <- df |>
transmute(
respondent_id = row_number(),
analysis_weight,
country = factor(
Country,
levels = model_country_levels
),
Age_group = as.character(Age_recoded_1),
Gender_group = recode(
as.character(Gender),
"98" = "No answer"
),
Employment_group = as.character(Employment),
Household_size = as.numeric(Nb_household),
recent_purchase_any_non_vape = if_any(
all_of(recent_purchase_cols),
~ as.character(.x) != "0"
)
)
summarise_binary_outcome_by_group <- function(data, outcome) {
outcome <- enquo(outcome)
summarise_dimension <- function(
variable,
dimension,
levels
) {
data |>
transmute(
Group = as.character(.data[[variable]]),
outcome = as.integer(!!outcome),
analysis_weight
) |>
filter(
!is.na(outcome),
Group %in% levels
) |>
group_by(Group) |>
summarise(
n = n(),
pct = weighted_mean_safe(outcome, analysis_weight),
.groups = "drop"
) |>
mutate(
Dimension = dimension,
Group = factor(
Group,
levels = levels
)
)
}
overall <- data |>
summarise(
pct = weighted_mean_safe(
as.integer(!!outcome),
analysis_weight
)
) |>
pull(pct)
desired_rows <- c(
paste(
"Age",
sociodemographic_age_levels,
sep = "___"
),
paste(
"Gender",
sociodemographic_gender_levels,
sep = "___"
),
paste(
"Employment",
sociodemographic_employment_levels,
sep = "___"
)
)
bind_rows(
summarise_dimension(
"Age_group",
"Age",
sociodemographic_age_levels
),
summarise_dimension(
"Gender_group",
"Gender",
sociodemographic_gender_levels
),
summarise_dimension(
"Employment_group",
"Employment",
sociodemographic_employment_levels
)
) |>
mutate(
Dimension = factor(
Dimension,
levels = c(
"Age",
"Gender",
"Employment"
)
),
Group_plot = paste(
Dimension,
as.character(Group),
sep = "___"
),
Group_plot = factor(
Group_plot,
levels = rev(desired_rows)
),
overall = overall,
comparison = if_else(
pct >= overall,
"At or above overall",
"Below overall"
)
)
}
estimate_household_size_curve <- function(data, outcome) {
outcome <- enquo(outcome)
model_data <- data |>
transmute(
Household_size,
country,
outcome = as.integer(!!outcome),
analysis_weight
) |>
drop_na() |>
droplevels()
model <- survey::svyglm(
outcome ~ Household_size + country,
design = make_analysis_design(model_data),
family = quasibinomial()
)
household_values <- seq(
min(model_data$Household_size),
max(model_data$Household_size),
length.out = 100
)
country_weights <- model_data |>
group_by(country) |>
summarise(country_n = sum(analysis_weight), .groups = "drop") |>
mutate(weight = country_n / sum(country_n))
prediction_data <- tidyr::crossing(
Household_size = household_values,
country = levels(model_data$country)
) |>
mutate(
country = factor(
country,
levels = levels(model_data$country)
)
) |>
left_join(
country_weights,
by = "country"
)
design_matrix <- model.matrix(
delete.response(terms(model)),
data = prediction_data
)
prediction_data$predicted <- plogis(
drop(design_matrix %*% coef(model))
)
map_dfr(
household_values,
function(household_size) {
rows <- which(
prediction_data$Household_size ==
household_size
)
probabilities <- prediction_data$predicted[rows]
weights <- prediction_data$weight[rows]
weighted_derivative <- weights *
probabilities *
(1 - probabilities)
gradient <- colSums(
design_matrix[rows, , drop = FALSE] *
weighted_derivative
)
standard_error <- sqrt(
drop(
t(gradient) %*%
vcov(model) %*%
gradient
)
)
estimate <- sum(weights * probabilities)
tibble(
Household_size = household_size,
pct = estimate,
lower = pmax(
0,
estimate - 1.96 * standard_error
),
upper = pmin(
1,
estimate + 1.96 * standard_error
)
)
}
)
}
summarise_household_size_observed <- function(data, outcome) {
outcome <- enquo(outcome)
data |>
transmute(
Household_size,
outcome = as.integer(!!outcome),
analysis_weight
) |>
drop_na() |>
group_by(Household_size) |>
summarise(
n = n(),
pct = weighted_mean_safe(outcome, analysis_weight),
.groups = "drop"
)
}
buying_sociodemo_summary <- summarise_binary_outcome_by_group(
sociodemo_cross_data,
recent_purchase_any_non_vape
)
buying_household_curve <- estimate_household_size_curve(
sociodemo_cross_data,
recent_purchase_any_non_vape
)
buying_household_observed <- summarise_household_size_observed(
sociodemo_cross_data,
recent_purchase_any_non_vape
)
buying_age_young <- buying_sociodemo_summary |>
filter(
Dimension == "Age",
Group == "18-29"
) |>
pull(pct)
buying_age_old <- buying_sociodemo_summary |>
filter(
Dimension == "Age",
Group == "65-99"
) |>
pull(pct)
buying_employment_high <- buying_sociodemo_summary |>
filter(Dimension == "Employment") |>
slice_max(
pct,
n = 1,
with_ties = FALSE
)
buying_employment_low <- buying_sociodemo_summary |>
filter(Dimension == "Employment") |>
slice_min(
pct,
n = 1,
with_ties = FALSE
)
household_volume_cols <- c(
"Household_Charging & lighting devices",
"Household_High-tech",
"Household_Gadgets & lifestyle items"
)
purchase_band_midpoint <- c(
"0" = 0,
"1" = 1,
"2 to 5" = 3.5,
"6 to 9" = 7.5,
"10 to 19" = 14.5,
"20+" = 20
)
disposal_band_midpoint <- c(
"1" = 0,
"2" = 1,
"3" = 3.5,
"4" = 7.5,
"5" = 14.5,
"6" = 20
)
purchase_disposal_data <- df |>
transmute(
analysis_weight,
purchased_count = rowSums(
across(
all_of(recent_purchase_cols),
~ unname(
purchase_band_midpoint[as.character(.x)]
)
)
),
disposed_count = rowSums(
across(
all_of(disposed_non_vape_cols),
~ unname(
disposal_band_midpoint[as.character(.x)]
)
)
)
) |>
drop_na()
purchase_disposal_rho <- weighted_correlation_safe(
rank(purchase_disposal_data$purchased_count),
rank(purchase_disposal_data$disposed_count),
purchase_disposal_data$analysis_weight
)
purchase_disposal_plot_limits <- c(
purchased = unname(quantile(
purchase_disposal_data$purchased_count,
0.98
)),
disposed = unname(quantile(
purchase_disposal_data$disposed_count,
0.98
))
)
purchase_disposal_plot_eligible <- purchase_disposal_data |>
filter(
purchased_count <= purchase_disposal_plot_limits[["purchased"]],
disposed_count <= purchase_disposal_plot_limits[["disposed"]]
)
set.seed(20260805)
purchase_disposal_plot_data <- purchase_disposal_plot_eligible |>
slice_sample(
n = min(
2500,
nrow(purchase_disposal_plot_eligible)
)
)
purchase_disposal_loess <- loess(
disposed_count ~ purchased_count,
data = purchase_disposal_plot_eligible,
weights = analysis_weight,
span = 0.8,
control = loess.control(surface = "direct")
)
purchase_disposal_gap_data <- tibble(
purchased_count = seq(
0,
min(purchase_disposal_plot_limits),
length.out = 200
)
) |>
mutate(
observed = pmax(
0,
predict(
purchase_disposal_loess,
newdata = pick(purchased_count)
)
),
benchmark = purchased_count,
lower = pmin(observed, benchmark),
upper = pmax(observed, benchmark)
) |>
drop_na()
country_purchase_summary <- df |>
group_by(Country) |>
summarise(
n = n(),
pct_recent_buyer = weighted_mean_safe(
Buy_12months == "Buyer",
analysis_weight
),
.groups = "drop"
) |>
arrange(desc(pct_recent_buyer))
country_ever_bought_summary <- df |>
group_by(Country) |>
summarise(
n = n(),
pct_ever_bought = weighted_mean_safe(
Buyer == "Buyer",
analysis_weight
),
.groups = "drop"
) |>
arrange(desc(pct_ever_bought))
country_ever_bought_high <- country_ever_bought_summary |>
slice_max(
pct_ever_bought,
n = 1,
with_ties = FALSE
)
country_ever_bought_low <- country_ever_bought_summary |>
slice_min(
pct_ever_bought,
n = 1,
with_ties = FALSE
)
country_recent_purchase_high <- country_purchase_summary |>
slice_max(
pct_recent_buyer,
n = 1,
with_ties = FALSE
)
country_recent_purchase_low <- country_purchase_summary |>
slice_min(
pct_recent_buyer,
n = 1,
with_ties = FALSE
)
country_buying_stage_summary <- country_ever_bought_summary |>
select(
Country,
`Ever bought` = pct_ever_bought
) |>
left_join(
country_purchase_summary |>
select(
Country,
`Purchased during the last 12 months` =
pct_recent_buyer
),
by = "Country"
) |>
pivot_longer(
-Country,
names_to = "Measure",
values_to = "pct"
) |>
mutate(
Measure = factor(
Measure,
levels = c(
"Purchased during the last 12 months",
"Ever bought"
)
)
)
country_category_owned_summary <- df |>
select(
Country,
analysis_weight,
all_of(household_volume_cols)
) |>
pivot_longer(
all_of(household_volume_cols),
names_to = "Category",
values_to = "Band"
) |>
filter(!is.na(Band)) |>
mutate(
Category = str_remove(
Category,
"^Household_"
),
owned = as.character(Band) != "0"
) |>
group_by(
Country,
Category
) |>
summarise(
n = n(),
pct = weighted_mean_safe(
owned,
analysis_weight
),
.groups = "drop"
) |>
group_by(Country) |>
mutate(
country_mean = mean(pct)
) |>
ungroup()
country_category_owned_extremes <-
country_category_owned_summary |>
group_by(Category) |>
summarise(
highest_country = Country[which.max(pct)],
highest_pct = max(pct),
lowest_country = Country[which.min(pct)],
lowest_pct = min(pct),
.groups = "drop"
)
extract_country_owned_extreme <- function(
category,
field
) {
country_category_owned_extremes |>
filter(Category == category) |>
pull(all_of(field))
}
country_category_disposed_summary <- df |>
select(
Country,
analysis_weight,
all_of(disposed_non_vape_cols)
) |>
pivot_longer(
all_of(disposed_non_vape_cols),
names_to = "Category",
values_to = "Code"
) |>
filter(!is.na(Code)) |>
mutate(
Category = str_remove(
Category,
"^12months_disposed_"
),
Category = recode(
Category,
"Gadget and lifestyle items" =
"Gadgets & lifestyle items"
),
disposed = as.character(Code) != "1"
) |>
group_by(
Country,
Category
) |>
summarise(
n = n(),
pct = weighted_mean_safe(
disposed,
analysis_weight
),
.groups = "drop"
) |>
group_by(Country) |>
mutate(
country_mean = mean(pct)
) |>
ungroup()
country_category_disposed_extremes <-
country_category_disposed_summary |>
group_by(Category) |>
summarise(
highest_country = Country[which.max(pct)],
highest_pct = max(pct),
lowest_country = Country[which.min(pct)],
lowest_pct = min(pct),
.groups = "drop"
)
extract_country_disposed_extreme <- function(
category,
field
) {
country_category_disposed_extremes |>
filter(Category == category) |>
pull(all_of(field))
}
country_category_purchase_summary <- df |>
select(
Country,
analysis_weight,
all_of(recent_purchase_cols)
) |>
pivot_longer(
all_of(recent_purchase_cols),
names_to = "Category",
values_to = "Band"
) |>
mutate(
Category = str_remove(
Category,
"^Buy_12months_"
),
bought = as.character(Band) != "0"
) |>
group_by(
Country,
Category
) |>
summarise(
n = sum(!is.na(bought)),
pct = weighted_mean_safe(bought, analysis_weight),
.groups = "drop"
) |>
group_by(Country) |>
mutate(
country_mean = mean(pct)
) |>
ungroup()
country_category_purchase_extremes <-
country_category_purchase_summary |>
group_by(Category) |>
summarise(
highest_country = Country[which.max(pct)],
highest_pct = max(pct),
lowest_country = Country[which.min(pct)],
lowest_pct = min(pct),
.groups = "drop"
)
extract_country_purchase_extreme <- function(category, field) {
country_category_purchase_extremes |>
filter(Category == category) |>
pull(all_of(field))
}
country_category_ownership_purchase_summary <- bind_rows(
country_category_owned_summary |>
transmute(
Country,
Category,
Measure = "Owned at home",
pct
),
country_category_purchase_summary |>
transmute(
Country,
Category,
Measure = "Purchased during the last 12 months",
pct
)
) |>
left_join(
country_category_owned_summary |>
distinct(
Country,
country_mean
),
by = "Country"
) |>
mutate(
Measure = factor(
Measure,
levels = c(
"Purchased during the last 12 months",
"Owned at home"
)
)
)
country_disposal_method_summary <- df |>
select(
Country,
analysis_weight,
all_of(disposal_method_cols)
) |>
pivot_longer(
all_of(disposal_method_cols),
names_to = "Method",
values_to = "Value"
) |>
filter(!is.na(Value)) |>
mutate(
Method = recode(
Method,
"Other...124" = "Other",
"I don’t remember...125" =
"I don’t remember"
),
selected = is_selected(Value),
method_type = case_when(
Method %in% c(
"I brought them to a recycling centre / municipal waste facility",
"I brought them to a recycling point in a store",
"I gave them away or donated",
"I sold them"
) ~ "Recycling or reuse",
Method %in% c(
"I threw them in general household waste",
"I threw them in the plastic waste bin",
"I threw them in a street bin"
) ~ "Waste disposal",
TRUE ~ "Kept, other or unknown"
)
) |>
group_by(
Country,
Method,
method_type
) |>
summarise(
n = sum(selected),
denominator = n(),
pct = weighted_mean_safe(selected, analysis_weight),
.groups = "drop"
)
country_disposal_method_levels <- c(
"I brought them to a recycling centre / municipal waste facility",
"I brought them to a recycling point in a store",
"I gave them away or donated",
"I sold them",
"I kept them",
"I threw them in general household waste",
"I threw them in the plastic waste bin",
"I threw them in a street bin",
"I don’t remember",
"Other"
)
recycling_centre_method <-
"I brought them to a recycling centre / municipal waste facility"
household_waste_method <-
"I threw them in general household waste"
country_disposal_method_extremes <-
country_disposal_method_summary |>
group_by(Method) |>
summarise(
highest_country = Country[which.max(pct)],
highest_pct = max(pct),
lowest_country = Country[which.min(pct)],
lowest_pct = min(pct),
.groups = "drop"
)
extract_country_disposal_extreme <- function(method, field) {
country_disposal_method_extremes |>
filter(Method == method) |>
pull(all_of(field))
}
test_country_disposal_difference <- function(method, label) {
test_data <- df |>
transmute(
Country = factor(Country),
analysis_weight,
response = .data[[method]]
) |>
filter(!is.na(response)) |>
mutate(
selected = factor(
is_selected(response),
levels = c(FALSE, TRUE)
)
)
design <- survey::svydesign(
ids = ~1,
weights = ~analysis_weight,
data = test_data
)
test <- survey::svychisq(
~selected + Country,
design = design,
statistic = "F"
)
tibble(
Method = label,
F = unname(test$statistic),
df_num = unname(test$parameter[[1]]),
df_den = unname(test$parameter[[2]]),
p_value = test$p.value
)
}
country_disposal_tests <- bind_rows(
test_country_disposal_difference(
recycling_centre_method,
"Recycling centre"
),
test_country_disposal_difference(
household_waste_method,
"Household waste"
)
)
recycling_country_test <- country_disposal_tests |>
filter(Method == "Recycling centre")
household_waste_country_test <- country_disposal_tests |>
filter(Method == "Household waste")exposure_score_levels <- c(
"Less or never" = 1,
"Few times a year" = 2,
"At least once a month" = 3,
"At least once a week" = 4,
"Everyday" = 5
)
exposure_volume_data <- df |>
transmute(
analysis_weight,
exposure_score = unname(
exposure_score_levels[as.character(`SeeFast-tech`)]
),
items_at_home = rowSums(
across(
all_of(household_volume_cols),
~ unname(purchase_band_midpoint[as.character(.x)])
)
),
items_purchased = rowSums(
across(
all_of(recent_purchase_cols),
~ unname(purchase_band_midpoint[as.character(.x)])
)
)
) |>
drop_na()
exposure_home_r <- weighted_correlation_safe(
exposure_volume_data$exposure_score,
exposure_volume_data$items_at_home,
exposure_volume_data$analysis_weight
)
exposure_purchase_r <- weighted_correlation_safe(
exposure_volume_data$exposure_score,
exposure_volume_data$items_purchased,
exposure_volume_data$analysis_weight
)
exposure_volume_plot_data <- exposure_volume_data |>
transmute(
analysis_weight,
Exposure = exposure_score,
`Items at home` = items_at_home,
`Items purchased during the last 12 months` = items_purchased
) |>
pivot_longer(
c(`Items at home`, `Items purchased during the last 12 months`),
names_to = "Measure",
values_to = "Estimated_items"
)
exposure_volume_summary <- unique(
exposure_volume_plot_data$Measure
) |>
map_dfr(function(measure) {
measure_data <- exposure_volume_plot_data |>
filter(Measure == measure)
survey::svyby(
~Estimated_items,
~Exposure,
make_analysis_design(measure_data),
survey::svymean,
vartype = "ci",
keep.names = FALSE
) |>
as_tibble() |>
transmute(
Measure = measure,
Exposure,
mean_items = Estimated_items,
conf_low = pmax(0, ci_l),
conf_high = ci_u
)
})Quite logically, the more respondents report to be exposed to fast tech, the more devices they have at home and the more they’ve bought during the last 12 months. We cannot know the direction of the relationship, though. Do they buy more because they are more frequently exposed? Do they remember or notice more being exposed because they buy or own more? Do we notice fast tech more if we own more of them?
exposure_volume_summary |>
ggplot(aes(
x = Exposure,
y = mean_items,
colour = Measure,
group = Measure
)) +
geom_errorbar(
aes(
ymin = conf_low,
ymax = conf_high
),
width = 0.08,
linewidth = bg_lw_hairline,
position = position_dodge(width = 0.16)
) +
geom_line(
linewidth = bg_lw_line,
position = position_dodge(width = 0.16)
) +
geom_point(
size = bg_point_size,
position = position_dodge(width = 0.16)
) +
scale_colour_manual(
values = c(
"Items at home" = bg_accent,
"Items purchased during the last 12 months" = bg_pink
),
name = NULL
) +
scale_x_continuous(
breaks = 1:5,
labels = c(
"Less or\nnever",
"A few times\na year",
"Monthly",
"Weekly",
"Every day"
)
) +
labs(
x = "Reported exposure frequency",
y = "Estimated number of items"
) +
theme(
legend.position = "top"
)The share of respondents who have ever bought fast tech ranges from France (82.6%) to Greece (95.6%). Recent purchasing ranges from Netherlands (70.1%) to Greece (88.8%).
country_buying_stage_summary |>
left_join(
country_ever_bought_summary |>
select(
Country,
country_order = pct_ever_bought
),
by = "Country"
) |>
mutate(
Country = fct_reorder(
Country,
country_order
)
) |>
ggplot(aes(
x = pct,
y = Country,
fill = Measure
)) +
geom_col(
position = position_dodge(
width = 0.76
),
width = 0.66
) +
geom_text(
aes(label = percent(
pct,
accuracy = 0.1
)),
position = position_dodge(
width = 0.76
),
hjust = -0.15,
size = bg_size_label / .pt
) +
scale_fill_manual(
name = NULL,
breaks = c(
"Ever bought",
"Purchased during the last 12 months"
),
values = c(
"Ever bought" = bg_accent,
"Purchased during the last 12 months" =
bg_accent_50
)
) +
scale_x_continuous(
labels = percent_format(),
limits = c(0, 1),
expand = expansion(
mult = c(0, 0.06)
)
) +
labs(
x = "% of respondents",
y = NULL
) +
theme(
legend.position = "top"
)Recent purchasing changes with age. It decreases from 93.1% among younger respondents (18–29) to 61.3% among those aged 65–99. By employment status, it is highest among student (94.4%) and lowest among retired (62.1%).
buying_sociodemo_summary |>
ggplot(aes(
x = pct,
y = Group_plot,
colour = comparison
)) +
geom_vline(
xintercept = buying_sociodemo_summary$overall[[1]],
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_segment(
aes(
x = overall,
xend = pct,
yend = Group_plot
),
colour = bg_ink_32,
linewidth = bg_lw_line
) +
geom_point(size = 3) +
geom_text(
aes(label = percent(pct, accuracy = 0.1)),
hjust = -0.35,
colour = bg_ink,
size = bg_size_label / .pt
) +
scale_colour_manual(
values = c(
"At or above overall" = bg_accent,
"Below overall" = bg_blue
),
guide = "none"
) +
scale_x_continuous(
labels = percent_format(),
limits = c(0, 1),
expand = expansion(mult = c(0, 0.06))
) +
scale_y_discrete(
labels = \(x) str_remove(x, "^.*___")
) +
facet_grid(
rows = vars(Dimension),
scales = "free_y",
space = "free_y"
) +
labs(
x = "% purchasing at least one item",
y = NULL
) +
theme(
strip.text.y = element_text(angle = 0)
)The curve shows the estimated probability of purchasing, with observed rates as points.
ggplot(
buying_household_curve,
aes(x = Household_size, y = pct)
) +
geom_ribbon(
aes(ymin = lower, ymax = upper),
fill = bg_accent_20,
alpha = bg_alpha_mute
) +
geom_line(
colour = bg_accent,
linewidth = bg_lw_line
) +
geom_point(
data = buying_household_observed,
aes(size = n),
colour = bg_ink
) +
scale_size_continuous(
name = "N",
range = c(2, 5)
) +
scale_x_continuous(
breaks = 1:10
) +
scale_y_continuous(
labels = percent_format(),
limits = c(0, 1),
expand = expansion(mult = c(0, 0.04))
) +
labs(
x = "Household size",
y = "Estimated probability of recent purchase"
)Ownership and recent purchasing follow similar country patterns, but ownership is consistently more frequent because it includes devices acquired before the last 12 months. For charging and lighting devices, ownership ranges from France (73.8%) to Ireland (90.9%), while recent purchasing ranges from France (59.5%) to Greece (80.3%).
country_category_ownership_purchase_summary |>
mutate(
Country = fct_reorder(
Country,
country_mean
),
Category = factor(
Category,
levels = c(
"Charging & lighting devices",
"High-tech",
"Gadgets & lifestyle items"
)
)
) |>
ggplot(aes(
x = pct,
y = Country,
fill = Measure
)) +
geom_col(
position = position_dodge(
width = 0.76
),
width = 0.66
) +
geom_text(
aes(label = percent(
pct,
accuracy = 0.1
)),
position = position_dodge(
width = 0.76
),
hjust = -0.15,
size = bg_size_label / .pt
) +
scale_fill_manual(
name = NULL,
breaks = c(
"Owned at home",
"Purchased during the last 12 months"
),
values = c(
"Owned at home" = bg_accent,
"Purchased during the last 12 months" =
bg_accent_50
)
) +
scale_x_continuous(
labels = percent_format(),
limits = c(0, 1),
expand = expansion(
mult = c(0, 0.07)
)
) +
facet_wrap(
~Category,
ncol = 1
) +
labs(
x = "% of respondents",
y = NULL
) +
theme(
legend.position = "top",
panel.spacing.y = grid::unit(
1.2,
"lines"
)
)Note: Percentages use the global analysis weight and are calculated separately within each country and category. Ownership refers to devices present in the household and may include devices used by other household members.
buying_reason_cols <- c(
"They are more affordable",
"They are the only available option",
"They are easily available or showcased",
"I read positive reviews about them",
"Promotions or special deals",
"People I trust recommend them",
"I see them in ads or on social media",
"Curiosity, for fun, or trying something new"
)
buying_reason_pay_more_summary <- df |>
select(
analysis_weight,
`Pay more`,
all_of(buying_reason_cols)
) |>
filter(!is.na(`Pay more`)) |>
pivot_longer(
all_of(buying_reason_cols),
names_to = "Reason",
values_to = "Value"
) |>
filter(!is.na(Value)) |>
mutate(
selected = is_selected(Value),
Willingness = factor(
`Pay more`,
levels = willingness_to_pay_levels
)
) |>
group_by(Willingness, Reason) |>
summarise(
n = sum(selected),
denominator = n(),
pct = weighted_mean_safe(
selected,
analysis_weight
),
.groups = "drop"
) |>
mutate(
Reason_plot = str_wrap(Reason, width = 34)
)
buying_reason_pay_more_extreme <-
buying_reason_pay_more_summary |>
slice_max(pct, n = 1, with_ties = FALSE)
if (!requireNamespace("nnet", quietly = TRUE)) {
stop("Install the 'nnet' package before rendering this section.")
}
buying_reason_wtp_model_data <- df |>
transmute(
willingness = factor(
as.character(`Pay more`),
levels = willingness_to_pay_levels,
ordered = FALSE
),
affordability = as.integer(
is_selected(`They are more affordable`)
),
promotions = as.integer(
is_selected(`Promotions or special deals`)
),
analysis_weight
) |>
drop_na()
buying_reason_wtp_fit_object <- fit_survey_multinomial(
willingness ~ affordability + promotions,
data = buying_reason_wtp_model_data
)
buying_reason_wtp_model <- buying_reason_wtp_fit_object$model
buying_reason_wtp_vcov <- buying_reason_wtp_fit_object$vcov
buying_reason_wtp_fit <- survey_multinomial_joint_test(
buying_reason_wtp_model,
buying_reason_wtp_vcov
) |>
mutate(
N = nrow(buying_reason_wtp_model_data),
.before = 1
)
buying_reason_wtp_predictor_tests <-
survey_multinomial_term_tests(
buying_reason_wtp_model,
buying_reason_wtp_vcov,
c("affordability", "promotions")
) |>
mutate(
Predictor = recode(
term,
affordability = "Affordability",
promotions = "Promotions or special deals"
)
) |>
select(Predictor, Wald, df, p_value)
average_buying_reason_wtp_probabilities <- function(
variable,
value
) {
new_data <- buying_reason_wtp_model_data
new_data[[variable]] <- value
predictions <- predict(
buying_reason_wtp_model,
newdata = new_data,
type = "probs"
)
colSums(
predictions *
buying_reason_wtp_model_data$analysis_weight
) / sum(buying_reason_wtp_model_data$analysis_weight)
}
buying_reason_wtp_effects <- imap_dfr(
c(
affordability = "Affordability",
promotions = "Promotions or special deals"
),
function(label, variable) {
difference <-
average_buying_reason_wtp_probabilities(variable, 1) -
average_buying_reason_wtp_probabilities(variable, 0)
tibble(
Predictor = label,
Willingness = names(difference),
probability_difference = as.numeric(difference)
)
}
)
affordability_some_effect <- buying_reason_wtp_effects |>
filter(
Predictor == "Affordability",
Willingness == "Yes, for some of them"
) |>
pull(probability_difference)
affordability_all_effect <- buying_reason_wtp_effects |>
filter(
Predictor == "Affordability",
Willingness == "Yes, for all of them"
) |>
pull(probability_difference)Is there a link between the reasons respondents have to buy fast tech and their willingness to pay more for a more durable device?
Affordability is the clearest factor. People who mention low prices as a reason for buying fast tech are 6.8% more likely to say they would pay more for only some longer-lasting devices, and 3.8% less likely to say they would pay more for all of them. Promotions do not show a clear link once affordability is taken into account.
buying_reason_pay_more_summary |>
ggplot(aes(
x = Willingness,
y = fct_rev(factor(
Reason_plot,
levels = unique(str_wrap(
buying_reason_cols,
width = 34
))
)),
fill = pct
)) +
geom_tile(
colour = bg_surface,
linewidth = bg_lw_hairline
) +
geom_text(
aes(label = percent(pct, accuracy = 0.1)),
size = (bg_size_label - 1) / .pt,
colour = bg_ink
) +
scale_fill_gradient(
low = bg_accent_20,
high = bg_accent,
guide = "none"
) +
labs(x = NULL, y = NULL) +
theme(
axis.text.x = element_text(angle = 25, hjust = 1),
panel.grid = element_blank(),
legend.position = "none"
)The model distinguishes the four willingness responses. The bars show how each buying reason is linked with each response. These links do not prove that one causes the other.
| N | Overall test | df | p-value |
|---|---|---|---|
| 9,154 | 46.058 | 6 | <0.001 |
| Predictor | Overall test | df | p-value |
|---|---|---|---|
| Affordability | 38.67 | 3 | <0.001 |
| Promotions or special deals | 6.09 | 3 | 0.11 |
| Willingness | Probability difference |
|---|---|
| Affordability | |
| No | 0.2% |
| Yes, for some of them | 6.8% |
| Yes, for most of them | −3.2% |
| Yes, for all of them | −3.8% |
| Promotions or special deals | |
| No | −1.7% |
| Yes, for some of them | 2.8% |
| Yes, for most of them | 0.4% |
| Yes, for all of them | −1.4% |
Note: Q10 allowed multiple responses, percentages within a column therefore do not sum to 100%.
channel_reason_categories <- c(
"Charging & lighting devices",
"High-tech",
"Gadgets & lifestyle items"
)
channel_reason_labels <- c(
"For work, study or everyday use",
"As a temporary replacement",
"I wanted a newer version",
"As a spare device",
"I bought it even though I didn't really need it",
"It was a gift for someone else",
"There was no other product / no other choice"
)
channel_reason_reason_plot_labels <- c(
"For work, study or everyday use" = "Everyday use",
"As a temporary replacement" = "Temporary replacement",
"I wanted a newer version" = "Newer version",
"As a spare device" = "Spare device",
"I bought it even though I didn't really need it" =
"Bought without need",
"It was a gift for someone else" = "Gift for someone else",
"There was no other product / no other choice" =
"No other choice"
)
channel_reason_columns <- unlist(
lapply(
channel_reason_categories,
\(category) paste0(
category,
"_",
channel_reason_labels
)
),
use.names = FALSE
)
channel_reason_channel_labels <- c(
"On the brand’s website",
"Online stores, marketplaces, social media",
"Tobacconist, convenience store",
"Physical stores, outdoor stall, market store",
"At the airport or a train station",
"As gifts, professional giveaways or merch",
"Second hand platform"
)
channel_reason_channel_plot_labels <- c(
"On the brand’s website" = "Brand website",
"Online stores, marketplaces, social media" =
"Online / marketplaces / social media",
"Tobacconist, convenience store" =
"Tobacconist / convenience store",
"Physical stores, outdoor stall, market store" =
"Physical stores / stalls / markets",
"At the airport or a train station" =
"Airport / train station",
"As gifts, professional giveaways or merch" =
"Gifts / giveaways / merch",
"Second hand platform" = "Second-hand platform"
)
channel_reason_responses <- df |>
mutate(respondent_id = row_number()) |>
select(
respondent_id,
analysis_weight,
all_of(channel_reason_columns)
) |>
pivot_longer(
cols = all_of(channel_reason_columns),
names_to = "Variable",
values_to = "Value"
) |>
mutate(
Reason = str_remove(Variable, "^[^_]+_"),
reason_presented = !is.na(Value),
reason_selected = is_selected(Value)
) |>
group_by(
respondent_id,
Reason
) |>
summarise(
analysis_weight = first(analysis_weight),
reason_presented = any(reason_presented),
reason_selected = any(reason_selected),
.groups = "drop"
) |>
filter(
reason_presented,
Reason %in% channel_reason_labels
)
channel_reason_channel_responses <- df |>
mutate(respondent_id = row_number()) |>
select(
respondent_id,
all_of(channel_reason_channel_labels)
) |>
pivot_longer(
cols = all_of(channel_reason_channel_labels),
names_to = "Channel",
values_to = "Value"
) |>
filter(!is.na(Value)) |>
transmute(
respondent_id,
Channel,
channel_selected = is_selected(Value)
)
channel_reason_analysis_data <- channel_reason_responses |>
inner_join(
channel_reason_channel_responses,
by = "respondent_id"
)
channel_reason_overall <- channel_reason_analysis_data |>
distinct(
respondent_id,
Reason,
.keep_all = TRUE
) |>
group_by(Reason) |>
summarise(
overall_pct = weighted_mean_safe(
reason_selected,
analysis_weight
),
.groups = "drop"
)
channel_reason_summary <- channel_reason_analysis_data |>
filter(channel_selected) |>
group_by(
Channel,
Reason
) |>
summarise(
n = n(),
pct = weighted_mean_safe(
reason_selected,
analysis_weight
),
.groups = "drop"
) |>
left_join(
channel_reason_overall,
by = "Reason"
) |>
mutate(
difference = pct - overall_pct,
Channel_plot = str_wrap(
unname(channel_reason_channel_plot_labels[Channel]),
width = 18
),
Reason_plot = str_wrap(
unname(channel_reason_reason_plot_labels[Reason]),
width = 18
)
)
channel_reason_difference_limit <- max(
abs(channel_reason_summary$difference)
)
channel_reason_key <- channel_reason_summary |>
slice_max(
abs(difference),
n = 1,
with_ties = FALSE
)
channel_reason_n <- channel_reason_analysis_data |>
distinct(respondent_id) |>
nrow()This comparison combines Q8b/Q9b across the three non-vape fast tech categories. A reason is counted once per respondent, even if it was selected for more than one category. The clearest difference is among respondents who selected “As gifts, professional giveaways or merch” as a usual channel: 44.7% also selected “I bought it even though I didn’t really need it” for at least one category, 22.0 percentage points above the overall share.
channel_reason_summary |>
mutate(
Channel_plot = factor(
Channel_plot,
levels = str_wrap(
unname(channel_reason_channel_plot_labels),
width = 18
)
),
Reason_plot = factor(
Reason_plot,
levels = str_wrap(
unname(channel_reason_reason_plot_labels),
width = 18
)
)
) |>
ggplot(aes(
x = Reason_plot,
y = fct_rev(Channel_plot),
fill = difference
)) +
geom_tile(
colour = bg_surface,
linewidth = bg_lw_hairline
) +
geom_text(
aes(label = percent(pct, accuracy = 1)),
size = (bg_size_label - 1) / .pt,
colour = bg_ink
) +
scale_fill_gradient2(
low = bg_pink,
mid = bg_surface,
high = bg_accent,
midpoint = 0,
limits = c(
-channel_reason_difference_limit,
channel_reason_difference_limit
),
breaks = c(-0.2, 0, 0.2),
labels = label_percent(accuracy = 1),
name = "Difference\nfrom overall"
) +
labs(
x = NULL,
y = NULL
) +
theme(
axis.text.x = element_text(
angle = 25,
hjust = 1,
lineheight = 0.9
),
panel.grid = element_blank(),
legend.position = "top"
)The percentages show the share of respondents using each channel who selected each reason for at least one category. The colour compares that percentage with the overall share for the same reason. Both questions allowed multiple responses, so the cells show co-occurrence rather than exclusive pathways. The analysis includes 8,205 respondents with both sets of answers.
shortest_duration_levels <- c(
"I got rid of it without ever using it",
"Single-use",
"Less than a week",
"Less than a month",
"1 to 6 months",
"6 to 12 months",
"12 months or more"
)
shortest_duration_age_data <- df |>
transmute(
Age_group = factor(
as.character(Age_recoded_1),
levels = sociodemographic_age_levels
),
Duration = factor(
as.character(Shortest_used),
levels = shortest_duration_levels,
ordered = TRUE
),
analysis_weight
) |>
drop_na()
shortest_duration_age_summary <-
shortest_duration_age_data |>
group_by(
Age_group,
Duration,
.drop = FALSE
) |>
summarise(
n = n(),
weighted_n = sum(analysis_weight),
.groups = "drop_last"
) |>
mutate(
pct = weighted_n / sum(weighted_n)
) |>
ungroup()
shortest_duration_under_six_by_age <-
shortest_duration_age_data |>
mutate(
no_more_than_six_months =
as.integer(Duration) <= 5
) |>
group_by(Age_group) |>
summarise(
n = n(),
pct = weighted_mean_safe(
no_more_than_six_months,
analysis_weight
),
.groups = "drop"
)
shortest_duration_young_pct <-
shortest_duration_under_six_by_age |>
filter(Age_group == "18-29") |>
pull(pct)
shortest_duration_old_pct <-
shortest_duration_under_six_by_age |>
filter(Age_group == "65-99") |>
pull(pct)
shortest_duration_age_test <- survey::svychisq(
~Age_group + Duration,
design = make_analysis_design(
shortest_duration_age_data
),
statistic = "F"
)Short recalled durations are more frequent among younger respondents. 80.1% of those aged 18–29 report that their shortest-lived device was used for no more than six months, compared with 55.7% of those aged 65–99. The full distribution differs by age group (p < .001).
shortest_duration_age_summary |>
ggplot(aes(
x = Duration,
y = fct_rev(Age_group),
fill = pct
)) +
geom_tile(
colour = bg_surface,
linewidth = 1
) +
geom_text(
aes(label = percent(pct, accuracy = 0.1)),
colour = bg_ink,
size = (bg_size_label - 1) / .pt
) +
scale_fill_bg_c(
palette = "seq",
labels = percent_format(),
name = "% within age group"
) +
scale_x_discrete(
labels = \(x) str_wrap(x, width = 17)
) +
labs(
x = "Shortest recalled duration of use",
y = "Age group"
) +
theme(
axis.text.x = element_text(
angle = 30,
hjust = 1
),
panel.grid = element_blank(),
legend.position = "top"
)This result describes each respondent’s shortest recalled experience, not the average lifespan of all their devices. Differences between age groups can also reflect different purchasing histories and recall, so they should not be interpreted as an effect of age alone.
Quite logically, end-of-life disposal follows purchasing volume. Respondents reporting more purchases also tend to report more disposal (correlation = 0.57).
purchase_disposal_plot_data |>
ggplot(aes(
x = purchased_count,
y = disposed_count
)) +
geom_ribbon(
data = purchase_disposal_gap_data,
aes(
x = purchased_count,
ymin = lower,
ymax = upper
),
inherit.aes = FALSE,
fill = bg_accent_20,
alpha = 0.55
) +
geom_abline(
intercept = 0,
slope = 1,
colour = bg_pink,
linetype = "dashed",
linewidth = bg_lw_line
) +
geom_jitter(
width = 0.55,
height = 0.55,
alpha = 0.16,
size = 1.2,
colour = bg_accent
) +
geom_smooth(
data = purchase_disposal_plot_eligible,
method = "loess",
formula = y ~ x,
span = 0.8,
se = FALSE,
colour = bg_accent,
linewidth = bg_lw_line
) +
scale_x_continuous(
breaks = pretty_breaks(n = 6),
expand = expansion(mult = c(0, 0.01))
) +
scale_y_continuous(
breaks = pretty_breaks(n = 6),
expand = expansion(mult = c(0, 0.01))
) +
coord_cartesian(
xlim = c(
0,
purchase_disposal_plot_limits[["purchased"]]
),
ylim = c(
0,
purchase_disposal_plot_limits[["disposed"]]
)
) +
labs(
x = "Estimated number purchased",
y = "Estimated number disposed of"
) +
theme(
legend.position = "none"
)End-of-life methods differ clearly between countries. Taking devices to a recycling centre ranges from Greece (16.8%) to Norway (47.6%).
Disposal in general household waste ranges from Poland (14.3%) to UK (35.5%).
The tests compare how often each method is selected in the 12 countries. They do not show which pairs of countries differ.
| Method | Overall test | df₁ | df₂ | p-value |
|---|---|---|---|---|
| Recycling centre | 18.5 | 5.6 | 51,499.4 | <0.001 |
| Household waste | 24.5 | 5.6 | 51,438.8 | <0.001 |
country_disposal_method_summary |>
mutate(
Country = factor(
Country,
levels = sort(unique(Country))
),
Method = factor(
Method,
levels = rev(country_disposal_method_levels)
),
Method_plot = factor(
str_wrap(
as.character(Method),
width = 42
),
levels = str_wrap(
rev(country_disposal_method_levels),
width = 42
)
)
) |>
ggplot(aes(
x = Country,
y = Method_plot,
fill = method_type,
alpha = pct
)) +
geom_tile(
colour = bg_surface,
linewidth = bg_lw_hairline
) +
geom_text(
aes(label = percent(pct, accuracy = 0.1)),
colour = bg_ink,
alpha = 1,
size = (bg_size_label - 1) / .pt,
show.legend = FALSE
) +
scale_fill_manual(
name = NULL,
values = c(
"Recycling or reuse" = bg_accent,
"Waste disposal" = bg_pink,
"Kept, other or unknown" = bg_ink_32
),
guide = "none"
) +
scale_alpha_continuous(
range = c(0.2, 1),
labels = percent_format(),
name = "% selecting"
) +
guides(
alpha = guide_legend(
order = 1,
nrow = 1
)
) +
labs(
x = NULL,
y = NULL
) +
theme(
axis.text.x = element_text(
angle = 45,
hjust = 1
),
panel.grid = element_blank(),
legend.position = "top",
legend.box = "vertical"
)model_country_levels <- sort(
unique(df$Country[!is.na(df$Country)])
)
figure_caption <- function(title, reading_key) {
paste0(
title,
"<br><span class='figure-key'><strong>How to read:</strong> ",
reading_key,
"</span>"
)
}
coefficient_term_label <- function(term) {
case_when(
term == "attitude_z" ~ "Attitude towards buying (1 SD)",
term == "injunctive_z" ~ "Injunctive norm (1 SD)",
term == "dynamic_z" ~ "Dynamic norm (1 SD)",
term == "buy_fewer_z" ~ "Support for buying fewer (1 SD)",
term == "regulation_z" ~ "Support for regulation (1 SD)",
term == "satisfaction_z" ~ "Device satisfaction (1 SD)",
term == "age_z" ~ "Age (1 SD)",
str_starts(term, "expected_lifespan") ~ paste0(
"Expected lifespan: ",
str_remove(term, "^expected_lifespan")
),
str_starts(term, "comparison_lifespan") ~ paste0(
"Lifespan compared with alternatives: ",
str_remove(term, "^comparison_lifespan")
),
str_starts(term, "gender") ~ paste0(
"Gender: ",
str_remove(term, "^gender")
),
TRUE ~ str_replace_all(term, "_", " ") |>
str_to_sentence()
)
}
model_coefficient_data <- function(
model,
robust_vcov = NULL,
pathway = NULL
) {
estimates <- coef(model)
if (is.matrix(estimates)) {
survey_estimates <- attr(
robust_vcov,
"survey_coef"
)
if (!is.null(survey_estimates)) {
estimates <- matrix(
survey_estimates,
nrow = nrow(estimates),
ncol = ncol(estimates),
byrow = TRUE,
dimnames = dimnames(estimates)
)
}
if (is.null(robust_vcov)) {
standard_errors <- summary(model)$standard.errors
} else {
standard_errors <- matrix(
sqrt(diag(robust_vcov)),
nrow = nrow(estimates),
ncol = ncol(estimates),
byrow = TRUE,
dimnames = dimnames(estimates)
)
}
result <- as.data.frame(as.table(estimates)) |>
as_tibble() |>
rename(
Outcome = Var1,
term = Var2,
beta = Freq
) |>
mutate(
standard_error = as.vector(standard_errors)
)
} else {
covariance <- robust_vcov %||% vcov(model)
result <- tibble(
term = names(estimates),
beta = as.numeric(estimates),
standard_error = sqrt(diag(covariance))
)
}
result |>
filter(
term != "(Intercept)",
!str_starts(term, "country")
) |>
mutate(
Predictor = map_chr(term, coefficient_term_label),
z = beta / standard_error,
p_value = 2 * pnorm(abs(z), lower.tail = FALSE),
conf_low = beta - qnorm(0.975) * standard_error,
conf_high = beta + qnorm(0.975) * standard_error,
Pathway = pathway,
.before = 1
) |>
select(
any_of(c("Pathway", "Outcome")),
Predictor,
beta,
standard_error,
conf_low,
conf_high,
p_value
)
}
coefficient_gt <- function(data) {
data <- data |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
)
)
group_column <- intersect(
c("Pathway", "Outcome"),
names(data)
) |>
first(default = NA_character_)
table <- if (is.na(group_column)) {
gt(data)
} else {
gt(data, groupname_col = group_column)
}
table |>
fmt_number(
columns = c(beta, standard_error, conf_low, conf_high),
decimals = 3
) |>
cols_label(
beta = "β",
standard_error = "SE",
conf_low = "95% CI low",
conf_high = "95% CI high",
p_value = "p-value"
) |>
cols_width(
Predictor ~ pct(42),
everything() ~ px(92)
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()
}
survey_binary_model_fit <- function(
model,
model_name,
model_terms
) {
test <- survey::regTermTest(
model,
reformulate(model_terms),
method = "Wald"
)
tibble(
Model = model_name,
N = nrow(model$survey.design$variables),
Wald = survey_test_statistic(test),
df = as.numeric(test$df),
p_value = as.numeric(test$p),
Pseudo_R2 = survey::psrsq(
model,
method = "Nagelkerke"
)
)
}
survey_multinomial_model_fit <- function(
model,
design_vcov,
model_name,
model_terms
) {
survey_multinomial_joint_test(
model,
design_vcov,
model_terms
) |>
mutate(
Model = model_name,
N = nrow(fitted(model)),
.before = 1
)
}
model_fit_gt <- function(data) {
table <- data |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_integer(columns = N) |>
fmt_number(
columns = any_of(c("Wald", "Pseudo_R2")),
decimals = 3
) |>
fmt_integer(columns = df) |>
cols_label(
Wald = "Overall test",
df = "df",
p_value = "p-value"
)
if ("Pseudo_R2" %in% names(data)) {
table <- table |>
cols_label(Pseudo_R2 = "Model fit")
}
table |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Model)
) |>
gt_bellegarde()
}Note: The following models take country, age and gender into account. They show links between answers, not causes.
ever_bought_base <- df |>
transmute(
analysis_weight,
ever_bought = factor(
if_else(
Buyer == "Buyer",
"Buyer",
"Non-buyer"
),
levels = c(
"Non-buyer",
"Buyer"
)
),
attitude = Attitude_buying_recoded,
injunctive = .data[["Injunctive norm_recoded"]],
dynamic = .data[["Dynamic norm_recoded"]],
buy_fewer = .data[["Consumers should buy fewer_recoded"]],
regulation = .data[[
"Governments should more strictly regulate_recoded"
]],
comparison_lifespan = factor(
Comparison_Lifespan,
levels = c(
"Similar lifespan",
"Shorter lifespan",
"Greater lifespan"
)
),
gender = factor(
if_else(
Gender %in% sociodemographic_gender_levels,
Gender,
NA_character_
),
levels = sociodemographic_gender_levels
),
country = factor(
Country,
levels = model_country_levels
),
age = Age
)
ever_model_data <- ever_bought_base |>
select(
ever_bought,
analysis_weight,
attitude,
injunctive,
dynamic,
buy_fewer,
regulation,
comparison_lifespan,
gender,
country,
age
) |>
drop_na() |>
mutate(
attitude_z = weighted_standardize(
attitude,
analysis_weight
),
injunctive_z = weighted_standardize(
injunctive,
analysis_weight
),
dynamic_z = weighted_standardize(
dynamic,
analysis_weight
),
buy_fewer_z = weighted_standardize(
buy_fewer,
analysis_weight
),
regulation_z = weighted_standardize(
regulation,
analysis_weight
),
age_z = weighted_standardize(
age,
analysis_weight
)
)
ever_model <- survey::svyglm(
ever_bought ~
attitude_z +
injunctive_z +
dynamic_z +
buy_fewer_z +
regulation_z +
comparison_lifespan +
gender +
country +
age_z,
design = make_analysis_design(ever_model_data),
family = quasibinomial()
)
survey_wald_term_tests <- function(
model,
terms
) {
map_dfr(
terms,
function(term) {
test <- survey::regTermTest(
model,
reformulate(term),
method = "Wald"
)
tibble(
term,
Wald = survey_test_statistic(test),
df = as.numeric(test$df),
p_value = as.numeric(test$p)
)
}
) |>
mutate(
q_value = p.adjust(
p_value,
method = "BH"
)
)
}
planned_lifespan_contrasts <- function(
model,
model_data,
type = c("response", "probs"),
vcov = NULL
) {
type <- match.arg(type)
lifespan_levels <- c(
"Similar lifespan",
"Shorter lifespan",
"Greater lifespan"
)
lifespan_counts <- tapply(
model_data$analysis_weight,
factor(
model_data$comparison_lifespan,
levels = lifespan_levels
),
sum
)
prediction_grid <- map_dfr(
lifespan_levels,
function(lifespan_level) {
model_data |>
mutate(
comparison_lifespan = factor(
lifespan_level,
levels = lifespan_levels
)
)
}
)
by_variables <- if (type == "probs") {
c("comparison_lifespan", "group")
} else {
"comparison_lifespan"
}
prediction_arguments <- list(
model = model,
newdata = prediction_grid,
by = by_variables,
type = type,
wts = "analysis_weight"
)
if (!is.null(vcov)) {
prediction_arguments$vcov <- vcov
}
predictions <- do.call(
marginaleffects::avg_predictions,
prediction_arguments
)
prediction_data <- predictions |>
as.data.frame() |>
as_tibble() |>
mutate(
prediction_row = row_number()
)
prediction_data$Outcome <- if (
"group" %in% names(prediction_data)
) {
as.character(prediction_data$group)
} else {
"Buyer"
}
prediction_vcov <- stats::vcov(predictions)
outcome_values <- unique(prediction_data$Outcome)
contrast_definitions <- list(
"Shorter vs non-shorter" = c(
"Similar lifespan" =
-lifespan_counts[["Similar lifespan"]] /
sum(lifespan_counts[c(
"Similar lifespan",
"Greater lifespan"
)]),
"Shorter lifespan" = 1,
"Greater lifespan" =
-lifespan_counts[["Greater lifespan"]] /
sum(lifespan_counts[c(
"Similar lifespan",
"Greater lifespan"
)])
),
"Greater vs non-greater" = c(
"Similar lifespan" =
-lifespan_counts[["Similar lifespan"]] /
sum(lifespan_counts[c(
"Similar lifespan",
"Shorter lifespan"
)]),
"Shorter lifespan" =
-lifespan_counts[["Shorter lifespan"]] /
sum(lifespan_counts[c(
"Similar lifespan",
"Shorter lifespan"
)]),
"Greater lifespan" = 1
)
)
contrast_rows <- crossing(
Outcome = outcome_values,
Contrast = names(contrast_definitions)
)
contrast_matrix <- matrix(
0,
nrow = nrow(contrast_rows),
ncol = nrow(prediction_data)
)
for (row_index in seq_len(nrow(contrast_rows))) {
outcome_value <- contrast_rows$Outcome[[row_index]]
contrast_name <- contrast_rows$Contrast[[row_index]]
weights <- contrast_definitions[[contrast_name]]
relevant_rows <- prediction_data |>
filter(Outcome == outcome_value)
contrast_matrix[
row_index,
relevant_rows$prediction_row
] <- weights[
as.character(relevant_rows$comparison_lifespan)
]
}
estimates <- as.numeric(
contrast_matrix %*% prediction_data$estimate
)
standard_errors <- sqrt(diag(
contrast_matrix %*%
prediction_vcov %*%
t(contrast_matrix)
))
contrast_rows |>
mutate(
term = "comparison_lifespan",
estimate = estimates,
std_error = standard_errors,
conf_low = estimate - qnorm(0.975) * std_error,
conf_high = estimate + qnorm(0.975) * std_error,
p_value = 2 * pnorm(
-abs(estimate / std_error)
)
)
}
ever_model_terms <- c(
"attitude_z",
"injunctive_z",
"dynamic_z",
"buy_fewer_z",
"regulation_z",
"comparison_lifespan",
"gender",
"country",
"age_z"
)
ever_term_tests <- survey_wald_term_tests(
ever_model,
ever_model_terms
)
ever_term_labels <- c(
attitude_z = "Attitude towards buying",
injunctive_z = "Injunctive norm",
dynamic_z = "Dynamic norm",
buy_fewer_z = "Support for buying fewer items",
regulation_z = "Support for stricter regulation",
comparison_lifespan = "Lifespan compared with alternatives",
gender = "Gender",
country = "Country",
age_z = "Age"
)
prepare_binary_marginal_effects <- function(
model,
term_tests,
model_data
) {
variables <- term_tests |>
filter(
q_value < 0.05,
!term %in% c(
"gender",
"country",
"age_z"
)
) |>
pull(term)
if (length(variables) == 0) {
return(tibble())
}
marginaleffects::avg_slopes(
model,
variables = variables,
type = "response",
wts = model_data$analysis_weight,
vcov = vcov(model)
) |>
as.data.frame() |>
as_tibble() |>
transmute(
term,
Predictor = if_else(
contrast == "dY/dX",
unname(ever_term_labels[term]),
paste0(
unname(ever_term_labels[term]),
": ",
str_replace(
contrast,
" - ",
" vs "
)
)
),
estimate,
conf_low = conf.low,
conf_high = conf.high
)
}
ever_marginal_effects <- prepare_binary_marginal_effects(
ever_model,
ever_term_tests,
ever_model_data
)
ever_lifespan_contrasts <- planned_lifespan_contrasts(
ever_model,
ever_model_data,
type = "response",
vcov = vcov(ever_model)
) |>
transmute(
term,
Predictor = paste0(
"Lifespan compared with alternatives: ",
Contrast
),
estimate,
conf_low,
conf_high
)
if (ever_term_tests$q_value[
ever_term_tests$term == "comparison_lifespan"
] < 0.05) {
ever_marginal_effects <- bind_rows(
ever_marginal_effects |>
filter(term != "comparison_lifespan"),
ever_lifespan_contrasts
)
} else {
ever_marginal_effects <- ever_marginal_effects |>
filter(term != "comparison_lifespan")
}
ever_attitude_effect <- ever_marginal_effects |>
filter(Predictor == "Attitude towards buying") |>
pull(estimate)
ever_model_overview <- ever_term_tests |>
mutate(
Predictor = unname(
ever_term_labels[term]
)
) |>
select(
Predictor,
Wald,
df,
p_value,
q_value
)
ever_global_fit <- survey_binary_model_fit(
ever_model,
"Ever having bought fast tech",
ever_model_terms
)
maximum_coefficient_vif <- function(model) {
matrix <- model.matrix(model)[
,
-1,
drop = FALSE
]
max(
map_dbl(
seq_len(ncol(matrix)),
function(index) {
focal <- matrix[, index]
others <- matrix[
,
-index,
drop = FALSE
]
auxiliary <- lm.fit(
cbind(intercept = 1, others),
focal
)
r_squared <- 1 -
sum(auxiliary$residuals^2) /
sum((focal - mean(focal))^2)
1 / (1 - r_squared)
}
)
)
}
ever_diagnostics <- tibble(
Check = c(
"Complete-case sample",
"Convergence",
"Maximum coefficient-level VIF",
"Smallest response group",
"Finite estimates"
),
Result = c(
format_count(nrow(ever_model_data)),
if_else(ever_model$converged, "Passed", "Failed"),
number(
maximum_coefficient_vif(ever_model),
accuracy = 0.01
),
format_count(
min(table(ever_model_data$ever_bought))
),
if_else(
all(
is.finite(coef(ever_model)),
is.finite(vcov(ever_model))
),
"Passed",
"Failed"
)
)
)This model estimates how attitudes, norms, lifespan perceptions and socio-demographic characteristics distinguish buyers from non-buyers. The chart expresses the results as changes in probability, so a value of +5% means an average increase of five percentage points in the probability of having bought fast tech.
| Model | N | Overall test | df | p-value | Model fit |
|---|---|---|---|---|---|
| Ever having bought fast tech | 10,182 | 26.245 | 21 | <0.001 | 0.238 |
ever_model_overview |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
),
q_value = format.pval(
q_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_number(
columns = Wald,
decimals = 1
) |>
cols_label(
Wald = "Overall test",
df = "df",
p_value = "p-value",
q_value = "Corrected p"
) |>
cols_width(
Predictor ~ pct(48),
Wald ~ px(105)
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()| Predictor | Overall test | df | p-value | Corrected p |
|---|---|---|---|---|
| Attitude towards buying | 70.5 | 1 | <0.001 | <0.001 |
| Injunctive norm | 18.1 | 1 | <0.001 | <0.001 |
| Dynamic norm | 3.4 | 1 | 0.067 | 0.086 |
| Support for buying fewer items | 0.3 | 1 | 0.609 | 0.686 |
| Support for stricter regulation | 0.1 | 1 | 0.768 | 0.768 |
| Lifespan compared with alternatives | 2.8 | 2 | 0.060 | 0.086 |
| Gender | 7.5 | 2 | <0.001 | 0.001 |
| Country | 6.9 | 11 | <0.001 | <0.001 |
| Age | 171.5 | 1 | <0.001 | <0.001 |
Coefficients are on the logistic scale. Country coefficients are included in the model but omitted from the table to keep the substantive predictors readable.
| Predictor | β | SE | 95% CI low | 95% CI high | p-value |
|---|---|---|---|---|---|
| Attitude towards buying (1 SD) | 0.590 | 0.070 | 0.453 | 0.728 | <0.001 |
| Injunctive norm (1 SD) | 0.256 | 0.060 | 0.138 | 0.374 | <0.001 |
| Dynamic norm (1 SD) | 0.090 | 0.049 | −0.006 | 0.187 | 0.067 |
| Support for buying fewer (1 SD) | −0.031 | 0.061 | −0.151 | 0.088 | 0.609 |
| Support for regulation (1 SD) | −0.017 | 0.059 | −0.133 | 0.098 | 0.768 |
| Lifespan compared with alternatives: Shorter lifespan | −0.051 | 0.112 | −0.271 | 0.168 | 0.645 |
| Lifespan compared with alternatives: Greater lifespan | −0.498 | 0.211 | −0.912 | −0.084 | 0.019 |
| Gender: Male | 0.335 | 0.101 | 0.137 | 0.532 | <0.001 |
| Gender: Non-binary | 1.631 | 0.736 | 0.189 | 3.073 | 0.027 |
| Age (1 SD) | −0.705 | 0.054 | −0.810 | −0.599 | <0.001 |
The model uses one record per respondent. For scored questions, it follows the overall rise or fall in answers.
ever_marginal_effects |>
mutate(
Predictor = fct_reorder(
str_wrap(Predictor, 38),
estimate
)
) |>
ggplot(aes(
x = estimate,
y = Predictor
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_errorbar(
aes(
xmin = conf_low,
xmax = conf_high
),
orientation = "y",
width = 0,
linewidth = bg_lw_line,
colour = bg_pink
) +
geom_point(
size = bg_size_label / .pt,
colour = bg_pink
) +
scale_x_continuous(
labels = label_percent(
accuracy = 0.1,
style_positive = "plus"
)
) +
labs(
x = "Average change in probability of being a buyer",
y = NULL
)Note: Product satisfaction and expected lifespan are not included, since they were asked only of previous buyers. For scored questions, each bar shows a typical increase. Only the clearest factors are shown. The model does not establish cause and effect.
if (!requireNamespace("lavaan", quietly = TRUE)) {
stop("Install the 'lavaan' package before rendering this section.")
}
behavioural_path_base <- ever_model_data |>
mutate(
ever_bought = ordered(
ever_bought,
levels = c("Non-buyer", "Buyer")
),
shorter_lifespan = as.numeric(
comparison_lifespan == "Shorter lifespan"
),
greater_lifespan = as.numeric(
comparison_lifespan == "Greater lifespan"
)
)
behavioural_path_controls <- model.matrix(
~ gender + country,
data = behavioural_path_base
)[, -1, drop = FALSE]
colnames(behavioural_path_controls) <- make.names(
colnames(behavioural_path_controls),
unique = TRUE
)
behavioural_path_data <- bind_cols(
behavioural_path_base,
as_tibble(behavioural_path_controls)
)
behavioural_path_control_terms <- colnames(
behavioural_path_controls
)
behavioural_path_model_syntax <- paste0(
"attitude_z ~ injunctive_z + dynamic_z + buy_fewer_z + ",
"regulation_z + shorter_lifespan + greater_lifespan + age_z + ",
paste(behavioural_path_control_terms, collapse = " + "),
"\n",
"ever_bought ~ attitude_z + injunctive_z + greater_lifespan + age_z + ",
paste(behavioural_path_control_terms, collapse = " + ")
)
behavioural_path_model <- lavaan::sem(
model = behavioural_path_model_syntax,
data = behavioural_path_data,
ordered = "ever_bought",
estimator = "WLSMV",
sampling.weights = "analysis_weight",
std.lv = TRUE
)
behavioural_path_fit <- lavaan::fitMeasures(
behavioural_path_model,
c("cfi", "tli", "rmsea", "srmr")
)
behavioural_path_labels <- c(
injunctive_z = "Perceived approval",
dynamic_z = "Dynamic norm",
buy_fewer_z = "Support for buying fewer",
regulation_z = "Support for regulation",
shorter_lifespan = "Shorter lifespan",
greater_lifespan = "Greater lifespan",
age_z = "Age (+1 SD)",
attitude_z = "Buying attitude",
ever_bought = "Ever bought fast tech"
)
behavioural_path_estimates <-
lavaan::standardizedSolution(behavioural_path_model) |>
as_tibble() |>
filter(
op == "~",
lhs %in% c("attitude_z", "ever_bought"),
rhs %in% names(behavioural_path_labels)
) |>
transmute(
Outcome = unname(behavioural_path_labels[lhs]),
Predictor = unname(behavioural_path_labels[rhs]),
from = rhs,
to = lhs,
beta = est.std,
standard_error = se,
p_value = pvalue,
Direction = if_else(beta >= 0, "Positive", "Negative")
)
behavioural_path_age_effect <- marginaleffects::avg_slopes(
ever_model,
variables = "age_z",
type = "response",
wts = ever_model_data$analysis_weight,
vcov = vcov(ever_model)
) |>
as.data.frame() |>
as_tibble() |>
transmute(
from = "age_z",
probability_difference = estimate,
conf_low = conf.low,
conf_high = conf.high
)
behavioural_path_purchase_effects <- bind_rows(
ever_marginal_effects |>
filter(Predictor == "Attitude towards buying") |>
transmute(
from = "attitude_z",
probability_difference = estimate,
conf_low,
conf_high
),
ever_marginal_effects |>
filter(Predictor == "Injunctive norm") |>
transmute(
from = "injunctive_z",
probability_difference = estimate,
conf_low,
conf_high
),
ever_marginal_effects |>
filter(str_detect(Predictor, "Greater vs")) |>
transmute(
from = "greater_lifespan",
probability_difference = estimate,
conf_low,
conf_high
),
behavioural_path_age_effect
)
behavioural_path_plot_data <- bind_rows(
behavioural_path_estimates |>
filter(to == "attitude_z") |>
transmute(
Stage = "Buying attitude\nEffect in standard deviations",
Predictor,
effect = beta,
conf_low = beta - 1.96 * standard_error,
conf_high = beta + 1.96 * standard_error,
Direction = if_else(effect >= 0, "Positive", "Negative"),
display_label = paste0(
number(
effect,
accuracy = 0.01,
style_positive = "plus"
),
" SD"
)
),
behavioural_path_purchase_effects |>
transmute(
Stage = "Ever bought fast tech\nEffect in percentage points",
Predictor = unname(behavioural_path_labels[from]),
effect = probability_difference * 100,
conf_low = conf_low * 100,
conf_high = conf_high * 100,
Direction = if_else(effect >= 0, "Positive", "Negative"),
display_label = paste0(
number(
effect,
accuracy = 0.1,
style_positive = "plus"
),
" pp"
)
)
)
path_attitude_purchase_effect <-
behavioural_path_purchase_effects |>
filter(from == "attitude_z") |>
pull(probability_difference)Perceived approval has the strongest link with buying attitude. People who feel stronger approval also tend to view fast tech more favourably. A typical increase in buying attitude is linked with a +5.4% difference in the probability of having bought fast tech.
behavioural_path_plot_data |>
mutate(
Predictor = fct_reorder(
str_wrap(Predictor, 28),
effect
)
) |>
ggplot(aes(
x = effect,
y = Predictor,
colour = Direction
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_errorbarh(
aes(xmin = conf_low, xmax = conf_high),
height = 0,
linewidth = bg_lw_line
) +
geom_point(size = bg_point_size) +
geom_text(
aes(
label = display_label,
hjust = if_else(effect >= 0, -0.18, 1.18)
),
colour = bg_ink,
size = bg_size_label / .pt,
show.legend = FALSE
) +
scale_colour_manual(
values = c(
Positive = bg_accent,
Negative = bg_pink
),
guide = "none"
) +
facet_wrap(
~Stage,
ncol = 1,
scales = "free"
) +
scale_x_continuous(
labels = label_number(
accuracy = 0.1,
style_positive = "plus"
),
expand = expansion(mult = c(0.18, 0.22))
) +
labs(
x = "Strength of link",
y = NULL
) +
theme(
panel.spacing.y = grid::unit(bg_margin_pt, "pt"),
strip.text = element_text(face = "bold")
)Arrows ending at buying attitude show the relative strength of each link. Arrows ending at purchase show average differences in probability.
behavioural_path_estimates |>
select(
Outcome,
Predictor,
beta,
standard_error,
p_value
) |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
)
) |>
gt(groupname_col = "Outcome") |>
fmt_number(
columns = c(beta, standard_error),
decimals = 3
) |>
cols_label(
beta = "Standardised β",
standard_error = "SE",
p_value = "p-value"
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()| Predictor | Standardised β | SE | p-value |
|---|---|---|---|
| Buying attitude | |||
| Perceived approval | 0.500 | 0.007 | <0.001 |
| Dynamic norm | 0.113 | 0.009 | <0.001 |
| Support for buying fewer | −0.106 | 0.011 | <0.001 |
| Support for regulation | −0.088 | 0.011 | <0.001 |
| Shorter lifespan | −0.138 | 0.012 | <0.001 |
| Greater lifespan | 0.046 | 0.009 | <0.001 |
| Age (+1 SD) | −0.030 | 0.011 | 0.0066 |
| Ever bought fast tech | |||
| Buying attitude | 0.266 | 0.025 | <0.001 |
| Perceived approval | 0.118 | 0.028 | <0.001 |
| Greater lifespan | −0.066 | 0.025 | 0.0077 |
| Age (+1 SD) | −0.320 | 0.023 | <0.001 |
The path model uses the same complete records as the buying model. Country and gender are included but omitted from the diagram. This is an exploratory view: the data cannot show which factor causes another.
required_packages <- c(
"nnet",
"marginaleffects"
)
missing_packages <- required_packages[
!vapply(
required_packages,
requireNamespace,
logical(1),
quietly = TRUE
)
]
if (length(missing_packages) > 0) {
stop(
"Install before rendering: ",
paste(missing_packages, collapse = ", ")
)
}
behavioural_predictor_data <- df |>
transmute(
respondent_id = row_number(),
analysis_weight,
satisfaction = Satisfaction_recoded,
expected_lifespan = factor(
Global_Lifespan,
levels = c(
"Expected lifespan",
"Shorter lifespan",
"Longer lifespan"
)
),
comparison_lifespan = factor(
Comparison_Lifespan,
levels = c(
"Similar lifespan",
"Shorter lifespan",
"Greater lifespan"
)
),
attitude = Attitude_buying_recoded,
injunctive = .data[["Injunctive norm_recoded"]],
dynamic = .data[["Dynamic norm_recoded"]],
buy_fewer = .data[["Consumers should buy fewer_recoded"]],
regulation = .data[[
"Governments should more strictly regulate_recoded"
]],
gender = factor(
if_else(
Gender %in% sociodemographic_gender_levels,
Gender,
NA_character_
),
levels = sociodemographic_gender_levels
),
country = factor(
Country,
levels = model_country_levels
),
age = Age
)
continuous_behavioural_predictors <- c(
"satisfaction",
"attitude",
"injunctive",
"dynamic",
"buy_fewer",
"regulation"
)
behavioural_predictor_names <- c(
"satisfaction",
"expected_lifespan",
"comparison_lifespan",
"attitude",
"injunctive",
"dynamic",
"buy_fewer",
"regulation"
)
behavioural_predictor_labels <- c(
satisfaction_z = "Device satisfaction",
expected_lifespan = "Expected lifespan",
expected_lifespan_longer =
"Expected lifespan",
comparison_lifespan =
"Lifespan compared with alternatives",
attitude_z = "Attitude towards buying",
injunctive_z = "Injunctive norm",
dynamic_z = "Dynamic norm",
buy_fewer_z = "Support for buying fewer items",
regulation_z = "Support for stricter regulation",
gender = "Gender",
country = "Country",
age_z = "Age"
)
build_behavioural_model_terms <- function(variables) {
c(
if_else(
variables %in% continuous_behavioural_predictors,
paste0(variables, "_z"),
variables
),
"gender",
"country",
"age_z"
)
}
prepare_behavioural_model_data <- function(
outcome_data,
outcome_name,
variables = behavioural_predictor_names
) {
outcome_data |>
left_join(
behavioural_predictor_data,
by = "respondent_id"
) |>
select(
all_of(outcome_name),
all_of(variables),
gender,
country,
age,
analysis_weight
) |>
drop_na() |>
mutate(
across(
all_of(intersect(
variables,
continuous_behavioural_predictors
)),
~ weighted_standardize(
.x,
analysis_weight
),
.names = "{.col}_z"
),
age_z = weighted_standardize(
age,
analysis_weight
)
)
}
pay_more_outcome_data <- df |>
transmute(
respondent_id = row_number(),
pay_more = factor(
as.character(.data[["Pay more"]]),
levels = willingness_to_pay_levels,
ordered = FALSE
)
)
pay_more_model_data <- prepare_behavioural_model_data(
pay_more_outcome_data,
"pay_more"
)
pay_more_terms <- build_behavioural_model_terms(
behavioural_predictor_names
)
pay_more_formula <- reformulate(
pay_more_terms,
response = "pay_more"
)
pay_more_fit <- fit_survey_multinomial(
pay_more_formula,
data = pay_more_model_data
)
pay_more_model <- pay_more_fit$model
pay_more_vcov <- pay_more_fit$vcov
pay_more_term_tests <- survey_multinomial_term_tests(
pay_more_model,
pay_more_vcov,
pay_more_terms
)
prepare_multinomial_marginal_effects <- function(
model,
term_tests,
model_data,
design_vcov
) {
variables <- term_tests |>
filter(
q_value < 0.05,
!term %in% c(
"gender",
"country",
"age_z"
)
) |>
pull(term)
marginaleffects::avg_slopes(
model,
variables = variables,
type = "probs",
wts = model_data$analysis_weight,
vcov = design_vcov
) |>
as.data.frame() |>
as_tibble() |>
transmute(
term,
Outcome = factor(
group,
levels = willingness_to_pay_levels,
labels = c(
"No",
"Yes: some",
"Yes: most",
"Yes: all"
)
),
Predictor = if_else(
contrast == "dY/dX",
unname(behavioural_predictor_labels[term]),
paste0(
unname(behavioural_predictor_labels[term]),
": ",
str_replace(
contrast,
" - ",
" vs "
)
)
),
estimate,
conf_low = conf.low,
conf_high = conf.high
)
}
pay_more_marginal_effects <- prepare_multinomial_marginal_effects(
pay_more_model,
pay_more_term_tests,
pay_more_model_data,
pay_more_vcov
)
pay_more_lifespan_contrasts <- planned_lifespan_contrasts(
pay_more_model,
pay_more_model_data,
type = "probs",
vcov = pay_more_vcov
) |>
transmute(
term,
Outcome = factor(
Outcome,
levels = willingness_to_pay_levels,
labels = c(
"No",
"Yes: some",
"Yes: most",
"Yes: all"
)
),
Predictor = paste0(
"Lifespan compared with alternatives: ",
Contrast
),
estimate,
conf_low,
conf_high
)
if (pay_more_term_tests$q_value[
pay_more_term_tests$term == "comparison_lifespan"
] < 0.05) {
pay_more_marginal_effects <- bind_rows(
pay_more_marginal_effects |>
filter(term != "comparison_lifespan"),
pay_more_lifespan_contrasts
)
} else {
pay_more_marginal_effects <- pay_more_marginal_effects |>
filter(term != "comparison_lifespan")
}
diagnostics_for_model <- function(
model,
data,
design_vcov,
outcome,
inference_converged
) {
tibble(
Check = c(
"Convergence",
"Predictor overlap",
"Smallest response group",
"Stable estimates",
"Uncertainty"
),
Result = c(
if_else(
model$convergence == 0 && inference_converged,
"Yes",
"No"
),
paste0(
"Low (",
scales::number(
maximum_coefficient_vif(model),
accuracy = 0.01
),
")"
),
paste0(
format_count(min(table(data[[outcome]]))),
" people"
),
if_else(
all(
is.finite(coef(model)),
is.finite(design_vcov)
),
"Yes",
"No"
),
"Included"
),
Interpretation = c(
"Optimisation completed normally",
"The factors remain distinct",
"Every answer has enough people",
"No numerical warning",
"Ranges show uncertainty around each result"
)
)
}
pay_more_diagnostics <- diagnostics_for_model(
pay_more_model,
pay_more_model_data,
pay_more_vcov,
"pay_more",
pay_more_fit$inference_converged
)
pay_more_model_overview <- pay_more_term_tests |>
mutate(
Predictor = unname(behavioural_predictor_labels[term])
) |>
select(
Predictor,
Wald,
df,
p_value,
q_value
)
pay_more_global_fit <- survey_multinomial_model_fit(
pay_more_model,
pay_more_vcov,
"Willingness to pay more",
pay_more_terms
)This model shows which factors shift respondents between the four willingness-to-pay responses. The chart reports changes in probability for each answer, making the direction and size of those shifts directly readable.
| Model | N | Overall test | df | p-value |
|---|---|---|---|---|
| Willingness to pay more | 9,098 | 675.259 | 72 | <0.001 |
pay_more_model_overview |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
),
q_value = format.pval(
q_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_number(
columns = Wald,
decimals = 1
) |>
cols_label(
Wald = "Overall test",
df = "df",
p_value = "p-value",
q_value = "Corrected p"
) |>
cols_width(
Predictor ~ pct(48),
Wald ~ px(105)
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()| Predictor | Overall test | df | p-value | Corrected p |
|---|---|---|---|---|
| Device satisfaction | 24.9 | 3 | <0.001 | <0.001 |
| Expected lifespan | 40.2 | 6 | <0.001 | <0.001 |
| Lifespan compared with alternatives | 45.0 | 6 | <0.001 | <0.001 |
| Attitude towards buying | 3.9 | 3 | 0.2779 | 0.2779 |
| Injunctive norm | 13.6 | 3 | 0.0035 | 0.0048 |
| Dynamic norm | 12.5 | 3 | 0.0058 | 0.0071 |
| Support for buying fewer items | 25.1 | 3 | <0.001 | <0.001 |
| Support for stricter regulation | 56.1 | 3 | <0.001 | <0.001 |
| Gender | 17.8 | 6 | 0.0069 | 0.0075 |
| Country | 185.7 | 33 | <0.001 | <0.001 |
| Age | 87.2 | 3 | <0.001 | <0.001 |
Each outcome is compared with the model’s reference response. Country coefficients are omitted from display but remain in the model.
| Predictor | β | SE | 95% CI low | 95% CI high | p-value |
|---|---|---|---|---|---|
| Yes, for some of them | |||||
| Device satisfaction (1 SD) | −0.024 | 0.049 | −0.119 | 0.071 | 0.6231 |
| Expected lifespan: Shorter lifespan | 0.023 | 0.113 | −0.198 | 0.243 | 0.8404 |
| Expected lifespan: Longer lifespan | −0.349 | 0.130 | −0.603 | −0.095 | 0.0071 |
| Lifespan compared with alternatives: Shorter lifespan | 0.460 | 0.095 | 0.274 | 0.646 | <0.001 |
| Lifespan compared with alternatives: Greater lifespan | 0.292 | 0.175 | −0.051 | 0.636 | 0.0952 |
| Attitude towards buying (1 SD) | −0.026 | 0.057 | −0.138 | 0.086 | 0.6502 |
| Injunctive norm (1 SD) | 0.028 | 0.053 | −0.076 | 0.131 | 0.6013 |
| Dynamic norm (1 SD) | 0.105 | 0.048 | 0.011 | 0.200 | 0.0283 |
| Support for buying fewer (1 SD) | 0.107 | 0.052 | 0.005 | 0.209 | 0.0389 |
| Support for regulation (1 SD) | 0.171 | 0.049 | 0.075 | 0.266 | <0.001 |
| Gender: Male | −0.014 | 0.083 | −0.177 | 0.149 | 0.8665 |
| Gender: Non-binary | 0.922 | 0.443 | 0.054 | 1.789 | 0.0374 |
| Age (1 SD) | −0.296 | 0.045 | −0.383 | −0.208 | <0.001 |
| Yes, for most of them | |||||
| Device satisfaction (1 SD) | 0.146 | 0.062 | 0.025 | 0.266 | 0.0183 |
| Expected lifespan: Shorter lifespan | 0.276 | 0.136 | 0.009 | 0.544 | 0.0426 |
| Expected lifespan: Longer lifespan | −0.029 | 0.174 | −0.370 | 0.313 | 0.8699 |
| Lifespan compared with alternatives: Shorter lifespan | 0.495 | 0.121 | 0.258 | 0.732 | <0.001 |
| Lifespan compared with alternatives: Greater lifespan | 0.831 | 0.202 | 0.435 | 1.227 | <0.001 |
| Attitude towards buying (1 SD) | −0.120 | 0.070 | −0.257 | 0.017 | 0.0852 |
| Injunctive norm (1 SD) | 0.151 | 0.067 | 0.019 | 0.283 | 0.0246 |
| Dynamic norm (1 SD) | 0.099 | 0.057 | −0.013 | 0.211 | 0.0820 |
| Support for buying fewer (1 SD) | 0.272 | 0.065 | 0.145 | 0.399 | <0.001 |
| Support for regulation (1 SD) | 0.334 | 0.061 | 0.214 | 0.454 | <0.001 |
| Gender: Male | 0.276 | 0.106 | 0.069 | 0.483 | 0.0089 |
| Gender: Non-binary | 0.748 | 0.499 | −0.229 | 1.725 | 0.1335 |
| Age (1 SD) | −0.496 | 0.054 | −0.602 | −0.389 | <0.001 |
| Yes, for all of them | |||||
| Device satisfaction (1 SD) | 0.396 | 0.111 | 0.179 | 0.613 | <0.001 |
| Expected lifespan: Shorter lifespan | 0.800 | 0.198 | 0.412 | 1.189 | <0.001 |
| Expected lifespan: Longer lifespan | 0.731 | 0.240 | 0.260 | 1.202 | 0.0023 |
| Lifespan compared with alternatives: Shorter lifespan | 0.456 | 0.195 | 0.074 | 0.839 | 0.0195 |
| Lifespan compared with alternatives: Greater lifespan | 0.913 | 0.269 | 0.385 | 1.440 | <0.001 |
| Attitude towards buying (1 SD) | −0.051 | 0.113 | −0.272 | 0.171 | 0.6549 |
| Injunctive norm (1 SD) | 0.305 | 0.102 | 0.105 | 0.504 | 0.0027 |
| Dynamic norm (1 SD) | 0.334 | 0.098 | 0.143 | 0.525 | <0.001 |
| Support for buying fewer (1 SD) | 0.402 | 0.110 | 0.187 | 0.617 | <0.001 |
| Support for regulation (1 SD) | 0.697 | 0.108 | 0.486 | 0.908 | <0.001 |
| Gender: Male | 0.232 | 0.166 | −0.092 | 0.557 | 0.1607 |
| Gender: Non-binary | 0.308 | 0.740 | −1.143 | 1.759 | 0.6773 |
| Age (1 SD) | −0.431 | 0.087 | −0.602 | −0.259 | <0.001 |
The model treats the four answers separately. The checks show whether the calculation was stable and whether every answer has enough people.
| Check | Result | Interpretation |
|---|---|---|
| Convergence | Yes | Optimisation completed normally |
| Predictor overlap | Low (1.70) | The factors remain distinct |
| Smallest response group | 492 people | Every answer has enough people |
| Stable estimates | Yes | No numerical warning |
| Uncertainty | Included | Ranges show uncertainty around each result |
pay_more_marginal_effects |>
mutate(
Predictor = fct_reorder(
str_wrap(Predictor, 42),
estimate
)
) |>
ggplot(aes(
x = estimate,
y = Predictor,
colour = Outcome
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_errorbar(
aes(
xmin = conf_low,
xmax = conf_high
),
orientation = "y",
position = position_dodge(width = 0.65),
width = 0,
linewidth = bg_lw_line
) +
geom_point(
position = position_dodge(width = 0.65),
size = bg_size_label / .pt
) +
scale_x_continuous(
labels = label_percent(accuracy = 0.1)
) +
scale_colour_manual(
values = c(
"No" = bg_ink_54,
"Yes: some" = bg_accent_20,
"Yes: most" = bg_accent_50,
"Yes: all" = bg_accent
),
name = NULL
) +
labs(
x = "Change in probability",
y = NULL
) +
theme(
legend.position = "top"
)Note: For scored questions, each bar shows a typical increase. The lifespan bars compare shorter with other answers, and greater with other answers. Positive and negative values show how probability moves between the four choices. The model does not establish causality.
fit_multinomial_specification <- function(
outcome_data,
outcome_name,
outcome_levels
) {
variables <- behavioural_predictor_names
model_data <- prepare_behavioural_model_data(
outcome_data,
outcome_name,
variables
)
terms <- build_behavioural_model_terms(variables) |>
setdiff("gender")
formula <- reformulate(
terms,
response = outcome_name
)
survey_fit <- fit_survey_multinomial(
formula,
data = model_data
)
model <- survey_fit$model
design_vcov <- survey_fit$vcov
model_tests <- survey_multinomial_term_tests(
model,
design_vcov,
terms
)
marginal_effect_variables <- model_tests |>
filter(
q_value < 0.05,
!term %in% c(
"gender",
"country",
"age_z"
)
) |>
pull(term)
marginal_effects <- marginaleffects::avg_slopes(
model,
variables = marginal_effect_variables,
type = "probs",
wts = model_data$analysis_weight,
vcov = design_vcov
) |>
as.data.frame() |>
as_tibble() |>
transmute(
term,
Outcome = factor(
group,
levels = outcome_levels
),
Predictor = if_else(
contrast == "dY/dX",
unname(
behavioural_predictor_labels[term]
),
paste0(
unname(
behavioural_predictor_labels[term]
),
": ",
str_replace(
contrast,
" - ",
" vs "
)
)
),
estimate,
conf_low = conf.low,
conf_high = conf.high
)
lifespan_contrasts <- planned_lifespan_contrasts(
model,
model_data,
type = "probs",
vcov = design_vcov
) |>
transmute(
term,
Outcome = factor(
Outcome,
levels = outcome_levels
),
Predictor = paste0(
"Lifespan compared with alternatives: ",
Contrast
),
estimate,
conf_low,
conf_high
)
if (model_tests$q_value[
model_tests$term == "comparison_lifespan"
] < 0.05) {
marginal_effects <- bind_rows(
marginal_effects |>
filter(term != "comparison_lifespan"),
lifespan_contrasts
)
} else {
marginal_effects <- marginal_effects |>
filter(term != "comparison_lifespan")
}
diagnostics <- tibble(
Check = c(
"Convergence",
"Predictor overlap",
"Smallest response group",
"Stable estimates",
"Uncertainty"
),
Result = c(
if_else(
model$convergence == 0 &&
survey_fit$inference_converged,
"Yes",
"No"
),
paste0(
"Low (",
scales::number(
maximum_coefficient_vif(model),
accuracy = 0.01
),
")"
),
paste0(
format_count(
min(table(model_data[[outcome_name]]))
),
" people"
),
if_else(
all(
is.finite(coef(model)),
is.finite(design_vcov)
),
"Yes",
"No"
),
"Included"
),
Interpretation = c(
"Optimisation completed normally",
"The factors remain distinct",
"Every answer has enough people",
"No numerical warning",
"Ranges show uncertainty around each result"
)
)
list(
data = model_data,
model = model,
vcov = design_vcov,
tests = model_tests,
marginal_effects = marginal_effects,
diagnostics = diagnostics
)
}
fit_outcome_model <- function(
outcome_data,
outcome_name,
outcome_levels
) {
results <- fit_multinomial_specification(
outcome_data,
outcome_name,
outcome_levels
)
overview <- results$tests |>
mutate(
Predictor = unname(
behavioural_predictor_labels[term]
)
) |>
select(
Predictor,
Wald,
df,
p_value,
q_value
)
list(
data = results$data,
model = results$model,
vcov = results$vcov,
tests = results$tests,
overview = overview,
marginal_effects = results$marginal_effects,
diagnostics = results$diagnostics
)
}
outcome_model_overview_table <- function(results) {
results$overview |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
),
q_value = format.pval(
q_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_number(
columns = Wald,
decimals = 1
) |>
cols_label(
Wald = "Overall test",
df = "df",
p_value = "p-value",
q_value = "Corrected p"
) |>
cols_width(
Predictor ~ pct(48),
Wald ~ px(105)
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()
}
outcome_model_diagnostics_table <- function(results) {
results$diagnostics |>
gt() |>
cols_width(
Check ~ pct(25),
Result ~ pct(22),
Interpretation ~ pct(53)
) |>
gt_bellegarde()
}replacement_outcome_data <- df |>
transmute(
respondent_id = row_number(),
replacement = factor(
Replace,
levels = replacement_choice_levels
)
) |>
filter(!is.na(replacement))
replacement_results <- fit_outcome_model(
replacement_outcome_data,
"replacement",
replacement_choice_levels
)
replacement_global_fit <- survey_multinomial_model_fit(
replacement_results$model,
replacement_results$vcov,
"Replacement choice",
replacement_results$tests$term
)This model shows which factors shift respondents between the five possible replacement choices. The chart expresses these results as probability differences for each choice.
| Model | N | Overall test | df | p-value |
|---|---|---|---|---|
| Replacement choice | 9,098 | 831.829 | 88 | <0.001 |
| Predictor | Overall test | df | p-value | Corrected p |
|---|---|---|---|---|
| Device satisfaction | 18.6 | 4 | <0.001 | 0.0016 |
| Expected lifespan | 44.9 | 8 | <0.001 | <0.001 |
| Lifespan compared with alternatives | 137.5 | 8 | <0.001 | <0.001 |
| Attitude towards buying | 40.6 | 4 | <0.001 | <0.001 |
| Injunctive norm | 16.4 | 4 | 0.0026 | 0.0032 |
| Dynamic norm | 6.8 | 4 | 0.1490 | 0.1656 |
| Support for buying fewer items | 39.6 | 4 | <0.001 | <0.001 |
| Support for stricter regulation | 4.8 | 4 | 0.3095 | 0.3095 |
| Country | 205.7 | 44 | <0.001 | <0.001 |
| Age | 16.8 | 4 | 0.0021 | 0.0030 |
Each replacement response is compared with the reference choice. Country coefficients are omitted from display but remain in the model.
| Predictor | β | SE | 95% CI low | 95% CI high | p-value |
|---|---|---|---|---|---|
| Buy another small and inexpensive device | |||||
| Device satisfaction (1 SD) | −0.137 | 0.057 | −0.249 | −0.024 | 0.0174 |
| Expected lifespan: Shorter lifespan | −0.304 | 0.116 | −0.532 | −0.076 | 0.0090 |
| Expected lifespan: Longer lifespan | −0.382 | 0.145 | −0.665 | −0.098 | 0.0084 |
| Lifespan compared with alternatives: Shorter lifespan | 0.079 | 0.102 | −0.122 | 0.280 | 0.4398 |
| Lifespan compared with alternatives: Greater lifespan | −0.256 | 0.166 | −0.581 | 0.070 | 0.1237 |
| Attitude towards buying (1 SD) | −0.153 | 0.058 | −0.266 | −0.040 | 0.0079 |
| Injunctive norm (1 SD) | −0.152 | 0.053 | −0.256 | −0.048 | 0.0041 |
| Dynamic norm (1 SD) | 0.014 | 0.049 | −0.082 | 0.110 | 0.7808 |
| Support for buying fewer (1 SD) | 0.055 | 0.054 | −0.052 | 0.162 | 0.3129 |
| Support for regulation (1 SD) | −0.031 | 0.050 | −0.129 | 0.067 | 0.5389 |
| Age (1 SD) | −0.031 | 0.046 | −0.121 | 0.060 | 0.5069 |
| Buy a more expensive one | |||||
| Device satisfaction (1 SD) | −0.132 | 0.059 | −0.248 | −0.015 | 0.0264 |
| Expected lifespan: Shorter lifespan | 0.151 | 0.115 | −0.074 | 0.376 | 0.1882 |
| Expected lifespan: Longer lifespan | −0.042 | 0.160 | −0.356 | 0.273 | 0.7949 |
| Lifespan compared with alternatives: Shorter lifespan | 0.965 | 0.107 | 0.756 | 1.174 | <0.001 |
| Lifespan compared with alternatives: Greater lifespan | 0.501 | 0.172 | 0.165 | 0.838 | 0.0035 |
| Attitude towards buying (1 SD) | −0.329 | 0.060 | −0.446 | −0.212 | <0.001 |
| Injunctive norm (1 SD) | −0.217 | 0.058 | −0.330 | −0.103 | <0.001 |
| Dynamic norm (1 SD) | 0.016 | 0.051 | −0.085 | 0.116 | 0.7569 |
| Support for buying fewer (1 SD) | 0.310 | 0.060 | 0.192 | 0.427 | <0.001 |
| Support for regulation (1 SD) | 0.056 | 0.056 | −0.053 | 0.165 | 0.3145 |
| Age (1 SD) | −0.082 | 0.048 | −0.176 | 0.013 | 0.0905 |
| Choose not to replace it | |||||
| Device satisfaction (1 SD) | −0.337 | 0.079 | −0.492 | −0.183 | <0.001 |
| Expected lifespan: Shorter lifespan | −0.032 | 0.169 | −0.363 | 0.300 | 0.8523 |
| Expected lifespan: Longer lifespan | 0.244 | 0.216 | −0.179 | 0.668 | 0.2575 |
| Lifespan compared with alternatives: Shorter lifespan | 0.270 | 0.155 | −0.035 | 0.574 | 0.0830 |
| Lifespan compared with alternatives: Greater lifespan | 0.350 | 0.254 | −0.149 | 0.849 | 0.1690 |
| Attitude towards buying (1 SD) | −0.441 | 0.088 | −0.614 | −0.269 | <0.001 |
| Injunctive norm (1 SD) | −0.201 | 0.090 | −0.378 | −0.023 | 0.0265 |
| Dynamic norm (1 SD) | −0.014 | 0.076 | −0.164 | 0.135 | 0.8534 |
| Support for buying fewer (1 SD) | 0.239 | 0.088 | 0.066 | 0.412 | 0.0067 |
| Support for regulation (1 SD) | −0.003 | 0.080 | −0.159 | 0.153 | 0.9685 |
| Age (1 SD) | 0.188 | 0.078 | 0.036 | 0.340 | 0.0155 |
| Other | |||||
| Device satisfaction (1 SD) | −0.101 | 0.179 | −0.451 | 0.250 | 0.5735 |
| Expected lifespan: Shorter lifespan | −0.854 | 0.389 | −1.617 | −0.091 | 0.0282 |
| Expected lifespan: Longer lifespan | 0.832 | 0.414 | 0.021 | 1.644 | 0.0444 |
| Lifespan compared with alternatives: Shorter lifespan | 0.390 | 0.357 | −0.310 | 1.090 | 0.2747 |
| Lifespan compared with alternatives: Greater lifespan | −1.300 | 0.802 | −2.872 | 0.271 | 0.1049 |
| Attitude towards buying (1 SD) | −0.318 | 0.176 | −0.663 | 0.026 | 0.0703 |
| Injunctive norm (1 SD) | −0.319 | 0.152 | −0.617 | −0.021 | 0.0357 |
| Dynamic norm (1 SD) | 0.364 | 0.144 | 0.081 | 0.646 | 0.0116 |
| Support for buying fewer (1 SD) | 0.020 | 0.166 | −0.306 | 0.345 | 0.9060 |
| Support for regulation (1 SD) | −0.205 | 0.188 | −0.574 | 0.164 | 0.2769 |
| Age (1 SD) | 0.237 | 0.164 | −0.085 | 0.558 | 0.1487 |
The model treats the five replacement choices separately. The checks show whether the calculation was stable and whether every answer has enough people.
| Check | Result | Interpretation |
|---|---|---|
| Convergence | Yes | Optimisation completed normally |
| Predictor overlap | Low (1.70) | The factors remain distinct |
| Smallest response group | 123 people | Every answer has enough people |
| Stable estimates | Yes | No numerical warning |
| Uncertainty | Included | Ranges show uncertainty around each result |
replacement_results$marginal_effects |>
mutate(
Predictor = fct_reorder(
str_wrap(Predictor, 42),
estimate
)
) |>
ggplot(aes(
x = estimate,
y = Predictor,
colour = Outcome
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_errorbar(
aes(
xmin = conf_low,
xmax = conf_high
),
orientation = "y",
position = position_dodge(width = 0.65),
width = 0,
linewidth = bg_lw_line
) +
geom_point(
position = position_dodge(width = 0.65),
size = bg_size_label / .pt
) +
scale_x_continuous(
labels = label_percent(
accuracy = 0.1,
style_positive = "plus"
)
) +
scale_colour_manual(
values = c(
"Buy the exact same small and inexpensive model" =
bg_accent_20,
"Buy another small and inexpensive device" =
bg_accent_50,
"Buy a more expensive one" = bg_accent,
"Choose not to replace it" = bg_pink,
"Other" = bg_ink_32
),
labels = function(x) {
str_wrap(x, width = 24)
},
name = NULL
) +
guides(
colour = guide_legend(
nrow = 3,
byrow = TRUE
)
) +
labs(
x = "Change in probability",
y = NULL
) +
theme(
legend.position = "bottom"
)Note: For scored questions, each bar shows a typical increase. The lifespan bars compare shorter with other answers, and greater with other answers. Positive and negative values show how probability moves between the replacement choices. The model does not establish causality.
future_intention_outcome_data <- df |>
transmute(
respondent_id = row_number(),
previous_buyer = Buyer == "Buyer",
future_intention = ordered(
.data[["12months_Intentionbuy"]],
levels = future_purchase_intention_levels
)
) |>
filter(
previous_buyer,
!is.na(future_intention)
) |>
mutate(
future_intention_score =
as.numeric(future_intention) - 1,
reduce_or_stop = as.integer(
future_intention_score <= 2
),
increase_purchases = as.integer(
future_intention_score >= 4
)
)
future_intention_score_data <-
prepare_behavioural_model_data(
future_intention_outcome_data |>
select(
respondent_id,
future_intention_score
),
"future_intention_score",
behavioural_predictor_names
)
future_intention_terms <- build_behavioural_model_terms(
behavioural_predictor_names
)
future_intention_score_formula <- reformulate(
future_intention_terms,
response = "future_intention_score"
)
future_intention_score_model <- survey::svyglm(
future_intention_score_formula,
design = make_analysis_design(
future_intention_score_data
),
family = gaussian()
)
future_intention_score_vcov <- vcov(
future_intention_score_model
)
future_intention_term_tests <-
survey_wald_term_tests(
future_intention_score_model,
future_intention_terms
)
future_intention_overview <-
future_intention_term_tests |>
mutate(
Predictor = unname(
behavioural_predictor_labels[term]
)
) |>
select(
Predictor,
Wald,
df,
p_value,
q_value
)
future_intention_significant_terms <-
future_intention_term_tests |>
filter(
q_value < 0.05,
!term %in% c(
"gender",
"country",
"age_z"
)
) |>
pull(term)
future_intention_score_effects <-
marginaleffects::avg_slopes(
future_intention_score_model,
variables = future_intention_significant_terms,
vcov = future_intention_score_vcov,
wts = future_intention_score_data$analysis_weight
) |>
as.data.frame() |>
as_tibble() |>
transmute(
term,
Predictor = if_else(
contrast == "dY/dX",
unname(behavioural_predictor_labels[term]),
paste0(
unname(behavioural_predictor_labels[term]),
": ",
str_replace(
contrast,
" - ",
" vs "
)
)
),
estimate,
conf_low = conf.low,
conf_high = conf.high
)
future_intention_lifespan_score_contrasts <-
planned_lifespan_contrasts(
future_intention_score_model,
future_intention_score_data,
type = "response",
vcov = future_intention_score_vcov
) |>
transmute(
term,
Predictor = paste0(
"Lifespan compared with alternatives: ",
Contrast
),
estimate,
conf_low,
conf_high
)
if (future_intention_term_tests$q_value[
future_intention_term_tests$term ==
"comparison_lifespan"
] < 0.05) {
future_intention_score_effects <- bind_rows(
future_intention_score_effects |>
filter(term != "comparison_lifespan"),
future_intention_lifespan_score_contrasts
)
} else {
future_intention_score_effects <-
future_intention_score_effects |>
filter(term != "comparison_lifespan")
}
fit_future_threshold_model <- function(outcome_name) {
model_data <- prepare_behavioural_model_data(
future_intention_outcome_data |>
select(
respondent_id,
all_of(outcome_name)
),
outcome_name,
behavioural_predictor_names
)
model_formula <- reformulate(
future_intention_terms,
response = outcome_name
)
model <- survey::svyglm(
model_formula,
design = make_analysis_design(model_data),
family = quasibinomial()
)
robust_vcov <- vcov(model)
contrasts <- planned_lifespan_contrasts(
model,
model_data,
type = "response",
vcov = robust_vcov
)
list(
data = model_data,
model = model,
vcov = robust_vcov,
contrasts = contrasts
)
}
future_reduce_model <- fit_future_threshold_model(
"reduce_or_stop"
)
future_increase_model <- fit_future_threshold_model(
"increase_purchases"
)
future_shorter_score <-
future_intention_lifespan_score_contrasts |>
filter(str_detect(
Predictor,
fixed("Shorter vs non-shorter")
))
future_greater_score <-
future_intention_lifespan_score_contrasts |>
filter(str_detect(
Predictor,
fixed("Greater vs non-greater")
))
future_shorter_reduce <-
future_reduce_model$contrasts |>
filter(Contrast == "Shorter vs non-shorter")
future_greater_reduce <-
future_reduce_model$contrasts |>
filter(Contrast == "Greater vs non-greater")
future_greater_increase <-
future_increase_model$contrasts |>
filter(Contrast == "Greater vs non-greater")
future_intention_diagnostics <- tibble(
Check = c(
"Complete responses",
"Answer scale",
"Uncertainty",
"Predictor overlap",
"Cross-check"
),
Result = c(
format_count(nrow(
future_intention_score_data
)),
"Six levels coded 0–5",
"Included",
paste0(
"Low (",
number(
maximum_coefficient_vif(
future_intention_score_model
),
accuracy = 0.01
),
")"
),
"Less, much less or stop"
),
Interpretation = c(
"Previous buyers with all required answers",
"Higher scores mean intending to buy more",
"Ranges show uncertainty around each result",
"The factors remain distinct",
"A separate yes/no model confirms the main result"
)
)
future_intention_global_test <- survey::regTermTest(
future_intention_score_model,
reformulate(future_intention_terms),
method = "Wald"
)
future_intention_global_fit <- tibble(
Model = "Future buying intention",
N = nrow(future_intention_score_data),
Wald = as.numeric(
survey_test_statistic(future_intention_global_test)
),
df = as.numeric(future_intention_global_test$df),
p_value = as.numeric(future_intention_global_test$p)
)The six answers are treated as an ordered continuum from 0 (I intend to stop) to 5 (Much more). A lower score therefore represents an intention to purchase less in the future.
Perceiving a shorter lifespan corresponds to a 0.21 points lower than for respondents not reporting a shorter lifespan. It is also associated with an increase of 12.1 percentage points in the probability of intending to buy less or stop.
The following visualisation shows factors driving intention. For scored questions such as attitudes and satisfaction, each bar shows a typical increase. The lifespan contrasts compare shorter with all non-shorter responses and greater with all non-greater responses. Negative values indicate lower future purchasing intentions; positive values indicate higher intentions.
| Model | N | Overall test | df | p-value |
|---|---|---|---|---|
| Future buying intention | 9,098 | 37.077 | 24 | <0.001 |
future_intention_overview |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
),
q_value = format.pval(
q_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_number(
columns = Wald,
decimals = 1
) |>
cols_label(
Wald = "Overall test",
df = "df",
p_value = "p-value",
q_value = "Corrected p"
) |>
cols_width(
Predictor ~ pct(46),
Wald ~ px(145)
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()| Predictor | Overall test | df | p-value | Corrected p |
|---|---|---|---|---|
| Device satisfaction | 21.9 | 1 | <0.001 | <0.001 |
| Expected lifespan | 4.5 | 2 | 0.012 | 0.014 |
| Lifespan compared with alternatives | 27.3 | 2 | <0.001 | <0.001 |
| Attitude towards buying | 54.6 | 1 | <0.001 | <0.001 |
| Injunctive norm | 18.5 | 1 | <0.001 | <0.001 |
| Dynamic norm | 0.0 | 1 | 0.997 | 0.997 |
| Support for buying fewer items | 70.6 | 1 | <0.001 | <0.001 |
| Support for stricter regulation | 0.3 | 1 | 0.595 | 0.655 |
| Gender | 18.3 | 2 | <0.001 | <0.001 |
| Country | 4.2 | 11 | <0.001 | <0.001 |
| Age | 66.0 | 1 | <0.001 | <0.001 |
Positive coefficients indicate a higher intended purchasing score. Country results are included in the model but not shown in the table.
| Predictor | β | SE | 95% CI low | 95% CI high | p-value |
|---|---|---|---|---|---|
| Device satisfaction (1 SD) | 0.084 | 0.018 | 0.049 | 0.119 | <0.001 |
| Expected lifespan: Shorter lifespan | −0.037 | 0.035 | −0.106 | 0.031 | 0.284 |
| Expected lifespan: Longer lifespan | −0.134 | 0.047 | −0.226 | −0.043 | 0.004 |
| Lifespan compared with alternatives: Shorter lifespan | −0.196 | 0.029 | −0.252 | −0.139 | <0.001 |
| Lifespan compared with alternatives: Greater lifespan | 0.101 | 0.064 | −0.024 | 0.226 | 0.114 |
| Attitude towards buying (1 SD) | 0.150 | 0.020 | 0.110 | 0.189 | <0.001 |
| Injunctive norm (1 SD) | 0.077 | 0.018 | 0.042 | 0.112 | <0.001 |
| Dynamic norm (1 SD) | 0.000 | 0.016 | −0.032 | 0.032 | 0.997 |
| Support for buying fewer (1 SD) | −0.163 | 0.019 | −0.201 | −0.125 | <0.001 |
| Support for regulation (1 SD) | −0.009 | 0.016 | −0.041 | 0.023 | 0.595 |
| Gender: Male | 0.160 | 0.027 | 0.108 | 0.212 | <0.001 |
| Gender: Non-binary | 0.071 | 0.154 | −0.230 | 0.373 | 0.643 |
| Age (1 SD) | −0.115 | 0.014 | −0.143 | −0.087 | <0.001 |
The answers run from stopping purchases to buying much more. The score model gives a clear overall direction, while a separate yes/no check confirms the main result for people who plan to buy less or stop.
| Check | Result | Interpretation |
|---|---|---|
| Complete responses | 9,098 | Previous buyers with all required answers |
| Answer scale | Six levels coded 0–5 | Higher scores mean intending to buy more |
| Uncertainty | Included | Ranges show uncertainty around each result |
| Predictor overlap | Low (1.70) | The factors remain distinct |
| Cross-check | Less, much less or stop | A separate yes/no model confirms the main result |
future_intention_score_effects |>
mutate(
Predictor = fct_reorder(
str_wrap(Predictor, 42),
estimate
)
) |>
ggplot(aes(
x = estimate,
y = Predictor
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_errorbar(
aes(
xmin = conf_low,
xmax = conf_high
),
orientation = "y",
width = 0,
linewidth = bg_lw_line,
colour = bg_accent
) +
geom_point(
size = bg_size_label / .pt,
colour = bg_accent
) +
scale_x_continuous(
labels = label_number(
accuracy = 0.05,
style_positive = "plus"
)
) +
labs(
x = paste0(
"Change in intention score ",
"(0 = stop; 5 = much more)"
),
y = NULL
)A comparative profile tests which observed characteristics distinguish one group of respondents over one outcome. It uses a deliberately small set of characteristics to produce easy-to-grasp audience groups associated with a defined behaviour. This approach doesn’t establishe causality.
profile_required_packages <- c(
"car",
"marginaleffects",
"rpart"
)
profile_missing_packages <- profile_required_packages[
!map_lgl(
profile_required_packages,
requireNamespace,
quietly = TRUE
)
]
if (length(profile_missing_packages) > 0) {
stop(
"Install the following packages before rendering: ",
paste(profile_missing_packages, collapse = ", ")
)
}
profile_age_levels <- c(
"18–29",
"30–39",
"40–54",
"55–64",
"65+"
)
profile_household_levels <- c(
"1",
"2",
"3",
"4",
"5+"
)
profile_education_levels <- c(
"No formal education / Primary school",
"Secondary school / High school",
"College / Undergraduate",
"Bachelor or equivalent",
"Master or equivalent / Doctorate, Ph.D. or equivalent"
)
comparative_profile_data <- df |>
mutate(
respondent_id = row_number()
) |>
transmute(
respondent_id,
analysis_weight,
buyer_status = factor(
if_else(Buyer == "Buyer", "Buyer", "Non-buyer"),
levels = c("Non-buyer", "Buyer")
),
age = Age,
age_group = cut(
Age,
breaks = c(-Inf, 29, 39, 54, 64, Inf),
labels = profile_age_levels
),
age_category = as.numeric(cut(
Age,
breaks = c(-Inf, 29, 39, 54, 64, Inf),
labels = FALSE
)),
household_size = as.numeric(Nb_household),
household_group = cut(
as.numeric(Nb_household),
breaks = c(-Inf, 1, 2, 3, 4, Inf),
labels = profile_household_levels
),
household_category = as.numeric(cut(
as.numeric(Nb_household),
breaks = c(-Inf, 1, 2, 3, 4, Inf),
labels = FALSE
)),
gender = factor(
if_else(
Gender %in% sociodemographic_gender_levels,
Gender,
NA_character_
),
levels = sociodemographic_gender_levels
),
education = factor(
if_else(
Education == "Prefer not to say",
NA_character_,
Education
),
levels = profile_education_levels
),
employment = factor(
if_else(
Employment == "Prefer not to say",
NA_character_,
Employment
),
levels = sociodemographic_employment_levels
),
satisfaction_score = as.numeric(
Satisfaction_recoded
),
expected_lifespan = factor(
Global_Lifespan,
levels = c(
"Expected lifespan",
"Shorter lifespan",
"Longer lifespan"
)
),
comparison_lifespan = factor(
Comparison_Lifespan,
levels = c(
"Similar lifespan",
"Shorter lifespan",
"Greater lifespan"
)
),
attitude_score = as.numeric(
Attitude_buying_recoded
),
injunctive_score = as.numeric(
.data[["Injunctive norm_recoded"]]
),
dynamic_score = as.numeric(
.data[["Dynamic norm_recoded"]]
),
buy_fewer_score = as.numeric(
.data[["Consumers should buy fewer_recoded"]]
),
regulation_score = as.numeric(
.data[["Governments should more strictly regulate_recoded"]]
),
country = factor(
Country,
levels = model_country_levels
)
)
profile_term_labels <- c(
age = "Age",
age_group = "Age group",
age_category = "Age category",
household_group = "Household size",
household_category = "Household-size category",
gender = "Gender",
education = "Education",
employment = "Employment status",
satisfaction_score = "Device satisfaction",
expected_lifespan = "Expected lifespan",
comparison_lifespan = "Lifespan versus alternatives",
attitude_score = "Attitude towards buying fast tech",
injunctive_score = "Perceived injunctive norm",
dynamic_score = "Perceived dynamic norm",
buy_fewer_score = "Consumers should buy fewer items",
regulation_score = "Support for stricter regulation",
country = "Country"
)
maximum_predictor_overlap <- function(model) {
vif_result <- suppressWarnings(car::vif(model))
if (is.matrix(vif_result)) {
if ("GVIF^(1/(2*Df))" %in% colnames(vif_result)) {
return(max(
vif_result[, "GVIF^(1/(2*Df))"],
na.rm = TRUE
))
}
return(max(
vif_result[, "GVIF"] ^
(1 / (2 * vif_result[, "Df"])),
na.rm = TRUE
))
}
max(sqrt(vif_result), na.rm = TRUE)
}
fit_comparative_profile <- function(
data,
outcome,
terms,
contrast_specifications,
linearity_variables = character()
) {
model_data <- data |>
select(all_of(c(outcome, terms)), analysis_weight) |>
drop_na()
model_formula <- reformulate(
terms,
response = outcome
)
model <- survey::svyglm(
model_formula,
design = make_analysis_design(model_data),
family = quasibinomial()
)
global_tests <- survey_wald_term_tests(
model,
terms
) |>
mutate(
Predictor = unname(profile_term_labels[term]),
significant = q_value < 0.05
)
probability_contrasts <- pmap_dfr(
contrast_specifications,
function(
variable,
low,
high,
Predictor,
Domain,
contrast_type
) {
if (contrast_type == "continuous") {
continuous_contrast <- if (variable == "age") {
marginaleffects::avg_comparisons(
model,
variables = setNames(list(10), variable),
type = "response",
wts = model_data$analysis_weight,
vcov = vcov(model)
)
} else {
marginaleffects::avg_slopes(
model,
variables = variable,
type = "response",
wts = model_data$analysis_weight,
vcov = vcov(model)
)
}
return(
continuous_contrast |>
as.data.frame() |>
as_tibble() |>
transmute(
variable,
Predictor,
Domain,
contrast = if_else(
variable == "age",
"Ten-year increase",
if_else(
variable == "household_category",
"One-category increase",
"One-point increase"
)
),
estimate,
conf_low = conf.low,
conf_high = conf.high,
p_value = p.value
)
)
}
marginaleffects::avg_comparisons(
model,
variables = setNames(
list(c(low, high)),
variable
),
type = "response",
wts = model_data$analysis_weight,
vcov = vcov(model)
) |>
as.data.frame() |>
as_tibble() |>
transmute(
variable,
Predictor,
Domain,
contrast = paste0(high, " vs ", low),
estimate,
conf_low = conf.low,
conf_high = conf.high,
p_value = p.value
)
}
) |>
mutate(
q_value = p.adjust(p_value, method = "BH")
)
outcome_counts <- table(model_data[[outcome]])
predictor_cells <- map_int(
terms,
function(term) {
cells <- table(
model_data[[outcome]],
model_data[[term]]
)
min(cells[cells > 0])
}
)
fitted_probabilities <- fitted(model)
observed_binary <- as.numeric(model_data[[outcome]]) - 1
tjur_r2 <-
weighted.mean(
fitted_probabilities[observed_binary == 1],
model_data$analysis_weight[observed_binary == 1]
) -
weighted.mean(
fitted_probabilities[observed_binary == 0],
model_data$analysis_weight[observed_binary == 0]
)
influential_threshold <- 4 / nrow(model_data)
influential_n <- sum(
cooks.distance(model) > influential_threshold,
na.rm = TRUE
)
linearity_checks <- if (
length(linearity_variables) == 0
) {
tibble(
term = character(),
Predictor = character(),
Wald = numeric(),
df = numeric(),
p_value = numeric(),
q_value = numeric(),
Result = character()
)
} else {
map_dfr(
linearity_variables,
function(variable) {
flexible_formula <- update(
model_formula,
as.formula(
paste0(
". ~ . - ",
variable,
" + factor(",
variable,
")"
)
)
)
flexible_model <- survey::svyglm(
flexible_formula,
design = make_analysis_design(model_data),
family = quasibinomial()
)
comparison <- stats::anova(
model,
flexible_model,
method = "Wald",
test = "F",
force = TRUE
)
tibble(
term = variable,
Wald = as.numeric(comparison$Ftest),
df = as.numeric(comparison$df),
p_value = as.numeric(comparison$p)
)
}
) |>
mutate(
q_value = p.adjust(
p_value,
method = "BH"
),
Predictor = unname(
profile_term_labels[term]
),
Result = if_else(
q_value < 0.05,
"Evidence of non-linearity",
"No evidence of non-linearity"
)
) |>
select(
term,
Predictor,
Wald,
df,
p_value,
q_value,
Result
)
}
diagnostics <- tibble(
Check = c(
"Complete-case sample",
"Outcome counts",
"Convergence",
"Finite coefficients",
"Predictor overlap",
"Smallest non-empty outcome × predictor cell",
"Predictor specification",
"Continuous-scale linearity check",
"Observations with Cook's distance > 4/N",
"Tjur's coefficient of discrimination"
),
Result = c(
format_count(nrow(model_data)),
paste(
names(outcome_counts),
format_count(as.numeric(outcome_counts)),
collapse = "; "
),
if_else(model$converged, "Passed", "Failed"),
if_else(
all(is.finite(coef(model))),
"Passed",
"Failed"
),
number(maximum_predictor_overlap(model), accuracy = 0.01),
format_count(min(predictor_cells)),
paste0(
"Age entered in years; household size entered as an ",
"ordered category score; lifespan and other ",
"socio-demographic variables categorical; rating ",
"scales continuous"
),
paste0(
sum(linearity_checks$q_value < 0.05),
" of ",
nrow(linearity_checks),
" scales show evidence of non-linearity"
),
paste0(
format_count(influential_n),
" (",
format_percent(influential_n / nrow(model_data)),
")"
),
number(tjur_r2, accuracy = 0.001)
)
)
list(
data = model_data,
model = model,
global_tests = global_tests,
probability_contrasts = probability_contrasts,
diagnostics = diagnostics,
linearity_checks = linearity_checks
)
}
profile_linearity_table <- function(results) {
results$linearity_checks |>
select(
Predictor,
Wald,
df,
q_value,
Result
) |>
gt() |>
cols_label(
Predictor = "Continuous scale",
Wald = "Overall test",
df = "df",
q_value = "Corrected p",
Result = "Assessment"
) |>
fmt_number(columns = Wald, decimals = 1) |>
fmt_number(columns = q_value, decimals = 3) |>
tab_style(
style = cell_text(weight = "bold"),
locations = cells_body(
rows = q_value < 0.05
)
) |>
gt_bellegarde()
}
profile_global_test_table <- function(results) {
results$global_tests |>
arrange(desc(significant), q_value) |>
select(Predictor, Wald, df, q_value) |>
gt() |>
cols_label(
Predictor = "Predictor",
Wald = "Overall test",
df = "df",
q_value = "Corrected p"
) |>
fmt_number(columns = Wald, decimals = 1) |>
fmt_number(columns = q_value, decimals = 3) |>
sub_missing(columns = q_value, missing_text = "—") |>
tab_style(
style = cell_text(weight = "bold"),
locations = cells_body(
rows = q_value < 0.05
)
) |>
gt_bellegarde()
}
plot_profile_contrasts <- function(results, positive_label) {
results$probability_contrasts |>
mutate(
Predictor_plot = paste0(Predictor, "\n", contrast),
Domain = factor(
Domain,
levels = c(
"Socio-demographic characteristics",
"Perceptions and behavioural factors"
)
)
) |>
ggplot(aes(
x = estimate,
y = fct_reorder(Predictor_plot, estimate)
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline
) +
geom_errorbarh(
aes(xmin = conf_low, xmax = conf_high),
height = 0,
colour = bg_pink,
linewidth = bg_lw_line
) +
geom_point(
colour = bg_pink,
size = bg_point_size
) +
scale_x_continuous(
labels = percent_format(accuracy = 1),
expand = expansion(mult = c(0.08, 0.08))
) +
facet_grid(
Domain ~ .,
scales = "free_y",
space = "free_y"
) +
labs(
x = paste0(
"Difference in probability of being a ",
positive_label
),
y = NULL
) +
theme(
panel.spacing.y = grid::unit(bg_margin_pt, "pt")
)
}The following model estimates the probability of having ever bought fast tech and compares buyers with non-buyers on socio-demographic characteristics, lifespan perceptions, buying attitude and perceived norms.
buyer_profile_terms <- c(
"age",
"household_category",
"gender",
"education",
"employment",
"comparison_lifespan",
"attitude_score",
"injunctive_score",
"dynamic_score",
"buy_fewer_score",
"regulation_score",
"country"
)
buyer_profile_contrasts <- tribble(
~variable, ~low, ~high, ~Predictor, ~Domain, ~contrast_type,
"age", NA_character_, NA_character_, "Age", "Socio-demographic characteristics", "continuous",
"household_category", NA_character_, NA_character_, "Household-size category", "Socio-demographic characteristics", "continuous",
"comparison_lifespan", "Shorter lifespan", "Greater lifespan", "Lifespan versus alternatives", "Perceptions and behavioural factors", "categorical",
"attitude_score", NA_character_, NA_character_, "Attitude towards buying fast tech", "Perceptions and behavioural factors", "continuous",
"injunctive_score", NA_character_, NA_character_, "Perceived injunctive norm", "Perceptions and behavioural factors", "continuous",
"dynamic_score", NA_character_, NA_character_, "Perceived dynamic norm", "Perceptions and behavioural factors", "continuous",
"buy_fewer_score", NA_character_, NA_character_, "Consumers should buy fewer items", "Perceptions and behavioural factors", "continuous",
"regulation_score", NA_character_, NA_character_, "Support for stricter regulation", "Perceptions and behavioural factors", "continuous"
)
buyer_profile_results <- fit_comparative_profile(
comparative_profile_data,
outcome = "buyer_status",
terms = buyer_profile_terms,
contrast_specifications = buyer_profile_contrasts,
linearity_variables = c(
"age",
"household_category",
"attitude_score",
"injunctive_score",
"dynamic_score",
"buy_fewer_score",
"regulation_score"
)
)
buyer_profile_significant <- buyer_profile_results$global_tests |>
filter(
significant,
term != "country"
) |>
pull(Predictor)
buyer_profile_significant_text <- paste(
buyer_profile_significant,
collapse = ", "
)
buyer_profile_key_effect <- buyer_profile_results$probability_contrasts |>
slice_max(
abs(estimate),
n = 1,
with_ties = FALSE
)
buyer_profile_age_effect <- buyer_profile_results$probability_contrasts |>
filter(variable == "age") |>
slice_head(n = 1)The analysis includes 10,030 respondents. It shows links, not causes. Over ten years of age, the probability of having bought fast tech is 3.3% lower (likely range: 2.3% to 4.3% lower).
| Predictor | Overall test | df | Corrected p |
|---|---|---|---|
| Attitude towards buying fast tech | 63.0 | 1 | 0.000 |
| Age | 48.8 | 1 | 0.000 |
| Household-size category | 37.5 | 1 | 0.000 |
| Country | 5.6 | 11 | 0.000 |
| Education | 6.9 | 4 | 0.000 |
| Perceived injunctive norm | 18.6 | 1 | 0.000 |
| Employment status | 2.9 | 6 | 0.013 |
| Gender | 4.7 | 2 | 0.013 |
| Lifespan versus alternatives | 4.4 | 2 | 0.016 |
| Perceived dynamic norm | 2.6 | 1 | 0.129 |
| Consumers should buy fewer items | 0.6 | 1 | 0.469 |
| Support for stricter regulation | 0.5 | 1 | 0.494 |
The model uses one record per person. Age, household size and the five rating scales are treated as gradual measures. Lifespan perception and the other personal characteristics are treated as categories. The results show links, not causes.
| Check | Result |
|---|---|
| Complete-case sample | 10,030 |
| Outcome counts | Non-buyer 1,066; Buyer 8,964 |
| Convergence | Passed |
| Finite coefficients | Passed |
| Predictor overlap | 1.54 |
| Smallest non-empty outcome × predictor cell | 1 |
| Predictor specification | Age entered in years; household size entered as an ordered category score; lifespan and other socio-demographic variables categorical; rating scales continuous |
| Continuous-scale linearity check | 6 of 7 scales show evidence of non-linearity |
| Observations with Cook's distance > 4/N | 683 (6.8%) |
| Tjur's coefficient of discrimination | 0.183 |
The following tests compare each one-slope specification with a model giving every response level its own coefficient. A significant result indicates that the continuous scale remains usable as a metric score, but that a single straight-line effect may be an oversimplification.
| Continuous scale | Overall test | df | Corrected p | Assessment |
|---|---|---|---|---|
| Age | 8.1 | 69 | 0.000 | Evidence of non-linearity |
| Household-size category | 0.5 | 3 | 0.690 | No evidence of non-linearity |
| Attitude towards buying fast tech | 12.8 | 3 | 0.000 | Evidence of non-linearity |
| Perceived injunctive norm | 10.0 | 3 | 0.000 | Evidence of non-linearity |
| Perceived dynamic norm | 18.2 | 3 | 0.000 | Evidence of non-linearity |
| Consumers should buy fewer items | 7.1 | 3 | 0.000 | Evidence of non-linearity |
| Support for stricter regulation | 10.0 | 3 | 0.000 | Evidence of non-linearity |
The smallest cell is driven by rare response combinations, especially the non-binary gender category. Its coefficient should therefore be interpreted cautiously. Observations exceeding the Cook’s-distance screening threshold are retained: this threshold flags cases for sensitivity review and is not an automatic exclusion rule.
weighted_mode_with_share <- function(x, weights) {
valid <- !is.na(x) & !is.na(weights) & weights > 0
if (!any(valid)) {
return(NA_character_)
}
distribution <- tibble(
value = as.character(x[valid]),
weight = weights[valid]
) |>
group_by(value) |>
summarise(weight = sum(weight), .groups = "drop")
modal_response <- distribution |>
slice_max(weight, n = 1, with_ties = FALSE)
paste0(
modal_response$value,
" (",
percent(
modal_response$weight / sum(distribution$weight),
accuracy = 0.1
),
")"
)
}
buyer_profile_metric_spec <- tribble(
~term, ~variable, ~Characteristic, ~metric, ~suffix,
"age", "age", "Mean age", "mean", " years",
"household_category", "household_size", "Mean household size", "mean", " people",
"education", "education", "Most common education level", "mode", "",
"employment", "employment", "Most common employment status", "mode", "",
"comparison_lifespan", "comparison_lifespan", "Most common comparative-lifespan response", "mode", "",
"attitude_score", "attitude_score", "Mean buying-attitude score", "mean", "/5",
"injunctive_score", "injunctive_score", "Mean perceived-approval score", "mean", "/5",
"buy_fewer_score", "buy_fewer_score", "Mean ‘buy fewer’ score", "mean", "/5",
"regulation_score", "regulation_score", "Mean regulation-support score", "mean", "/5"
)
buyer_profile_significant_terms <-
buyer_profile_results$global_tests |>
filter(significant, term != "country") |>
pull(term)
buyer_typical_profiles <- buyer_profile_metric_spec |>
filter(term %in% buyer_profile_significant_terms) |>
pmap_dfr(function(
term,
variable,
Characteristic,
metric,
suffix
) {
comparative_profile_data |>
group_by(buyer_status) |>
summarise(
Value = if (metric == "mean") {
paste0(
number(
weighted_mean_safe(
.data[[variable]],
analysis_weight
),
accuracy = 0.1
),
suffix
)
} else {
weighted_mode_with_share(
.data[[variable]],
analysis_weight
)
},
.groups = "drop"
) |>
mutate(
term = term,
Characteristic = Characteristic
)
}) |>
left_join(
buyer_profile_metric_spec |>
mutate(metric_order = row_number()) |>
select(term, metric_order),
by = "term"
) |>
arrange(metric_order) |>
select(Characteristic, buyer_status, Value) |>
pivot_wider(
names_from = buyer_status,
values_from = Value
)
buyer_profile_mean_age <- comparative_profile_data |>
group_by(buyer_status) |>
summarise(
mean_age = weighted_mean_safe(
age,
analysis_weight
),
.groups = "drop"
) |>
pivot_wider(
names_from = buyer_status,
values_from = mean_age
)This profile shows the average or most common answer in each group. Non-buyers are older on average (59.5 years) than buyers (47.7 years). This age difference remains clear when the other characteristics are considered.
| Characteristic | Non-buyer | Buyer |
|---|---|---|
| Mean age | 59.5 years | 47.7 years |
| Mean household size | 2.0 people | 2.7 people |
| Most common education level | Secondary school / High school (47.5%) | Secondary school / High school (33.9%) |
| Most common employment status | Retired (45.8%) | Employed (55.7%) |
| Most common comparative-lifespan response | Shorter lifespan (58.8%) | Similar lifespan (48.6%) |
| Mean buying-attitude score | 2.6/5 | 3.3/5 |
| Mean perceived-approval score | 2.8/5 | 3.3/5 |
The figure below isolates the main characteristic in the buying profile: attitude towards buying fast tech. It shows the probability of belonging to the buyer group at each of the five response levels. This is an observed gradient, not a causal effect of changing attitude.
format_action_tree_path <- function(path) {
conditions <- path |>
tail(-1)
if (length(conditions) == 0) {
return("All eligible respondents")
}
parsed_conditions <- str_remove_all(
conditions,
"\\s+"
) |>
str_match(
"^(age|attitude_score|injunctive_score)(>=|<)([0-9.]+)$"
) |>
as_tibble(
.name_repair = ~ c(
"condition",
"variable",
"operator",
"threshold"
)
) |>
mutate(
condition_order = row_number(),
threshold = as.numeric(threshold)
) |>
filter(!is.na(variable))
variable_labels <- c(
age = "Age",
attitude_score = "Buying attitude",
injunctive_score = "Perceived approval"
)
parsed_conditions |>
group_by(variable) |>
summarise(
condition_order = min(condition_order),
lower = if (any(operator == ">=")) {
max(threshold[operator == ">="])
} else {
-Inf
},
upper = if (any(operator == "<")) {
min(threshold[operator == "<"])
} else {
Inf
},
.groups = "drop"
) |>
arrange(condition_order) |>
mutate(
lower_display = ceiling(lower),
upper_display = floor(upper - 1e-8),
label = case_when(
is.finite(lower) & is.finite(upper) ~ paste0(
variable_labels[variable],
" ",
lower_display,
"–",
upper_display
),
is.finite(lower) ~ paste0(
variable_labels[variable],
" ≥ ",
lower_display
),
is.finite(upper) ~ paste0(
variable_labels[variable],
" ≤ ",
upper_display
)
)
) |>
pull(label) |>
paste(collapse = " and ")
}
fit_action_tree <- function(
data,
outcome,
seed = 20260805
) {
model_data <- data |>
select(
all_of(outcome),
age,
attitude_score,
injunctive_score,
analysis_weight
) |>
drop_na() |>
mutate(
outcome_numeric = as.integer(
.data[[outcome]] == "Yes"
)
)
set.seed(seed)
tree <- rpart::rpart(
outcome_numeric ~
age +
attitude_score +
injunctive_score,
data = model_data,
weights = analysis_weight,
method = "anova",
control = rpart::rpart.control(
maxdepth = 2,
minbucket = max(
100,
floor(0.05 * nrow(model_data))
),
cp = 0.001,
xval = 10
)
)
cp_table <- tree$cptable
best_row <- which.min(cp_table[, "xerror"])
selected_cp <- cp_table[best_row, "CP"]
tree <- rpart::prune(
tree,
cp = selected_cp
)
leaf_nodes <- as.integer(
row.names(tree$frame)[
tree$frame$var == "<leaf>"
]
)
tree_paths <- rpart::path.rpart(
tree,
nodes = leaf_nodes,
print.it = FALSE
)
profile_labels <- map_chr(
tree_paths,
format_action_tree_path
)
names(profile_labels) <- as.character(leaf_nodes)
profiles <- model_data |>
mutate(
leaf_node = row.names(tree$frame)[tree$where],
Profile = profile_labels[leaf_node]
) |>
group_by(Profile) |>
summarise(
N = n(),
weighted_N = sum(analysis_weight),
outcome_rate = weighted_mean_safe(
outcome_numeric,
analysis_weight
),
.groups = "drop"
) |>
arrange(outcome_rate) |>
mutate(
Profile = factor(Profile, levels = Profile)
)
node_numbers <- as.integer(row.names(tree$frame))
tree_depth <- max(
floor(log(node_numbers, base = 2))
)
list(
data = model_data,
tree = tree,
profiles = profiles,
diagnostics = tibble(
Check = c(
"Complete cases",
"Terminal profiles",
"Maximum tree depth",
"Smallest profile",
"Cross-validation"
),
Result = c(
format_count(nrow(model_data)),
nrow(profiles),
tree_depth,
format_count(min(profiles$N)),
"Used to choose tree size"
)
)
)
}
plot_action_tree_profiles <- function(
tree_results,
x_label
) {
tree_results$profiles |>
ggplot(aes(
x = outcome_rate,
y = Profile,
fill = outcome_rate
)) +
geom_col(width = bg_bar_width) +
geom_text(
aes(label = percent(
outcome_rate,
accuracy = 0.1
)),
hjust = -0.12,
size = bg_size_label / .pt
) +
scale_fill_bg_c(
palette = "seq",
guide = "none"
) +
scale_x_continuous(
labels = percent_format(),
breaks = c(0, 0.25, 0.50, 0.75, 1),
expand = expansion(mult = c(0, 0.02))
) +
scale_y_discrete(
labels = \(x) str_wrap(x, width = 38)
) +
coord_cartesian(
xlim = c(0, 1.12),
clip = "off"
) +
labs(
x = x_label,
y = NULL
)
}
buyer_action_profile_data <- comparative_profile_data |>
transmute(
buyer = factor(
if_else(
buyer_status == "Buyer",
"Yes",
"No"
),
levels = c("No", "Yes")
),
age,
attitude_score,
injunctive_score,
analysis_weight
)
buyer_attitude_design <- survey::svydesign(
ids = ~1,
weights = ~analysis_weight,
data = buyer_action_profile_data |>
mutate(buyer_numeric = as.numeric(buyer == "Yes"))
)
buyer_attitude_probability <- survey::svyby(
~buyer_numeric,
~attitude_score,
design = buyer_attitude_design,
FUN = survey::svymean,
vartype = "ci",
na.rm = TRUE
) |>
as.data.frame() |>
as_tibble() |>
transmute(
Attitude = factor(
attitude_score,
levels = 1:5,
labels = c(
"Completely unfavourable",
"Rather unfavourable",
"Neither favourable nor unfavourable",
"Rather favourable",
"Completely favourable"
)
),
probability = buyer_numeric,
conf_low = ci_l,
conf_high = ci_u
)The probability of being a previous buyer rises from 49.5% among respondents completely unfavourable towards buying to 94.0% among those completely favourable.
buyer_attitude_probability |>
ggplot(aes(x = Attitude, y = probability, group = 1)) +
geom_errorbar(
aes(ymin = conf_low, ymax = conf_high),
width = 0.12,
colour = bg_accent_50,
linewidth = bg_lw_line
) +
geom_line(
colour = bg_accent,
linewidth = bg_lw_line
) +
geom_point(
colour = bg_accent,
fill = bg_surface,
shape = 21,
stroke = bg_lw_line,
size = 3.2
) +
geom_text(
aes(label = percent(probability, accuracy = 0.1)),
vjust = -0.9,
size = bg_size_label / .pt
) +
scale_y_continuous(
labels = percent_format(),
limits = c(0, 1),
expand = expansion(mult = c(0, 0.06))
) +
scale_x_discrete(labels = \(x) str_wrap(x, width = 18)) +
labs(
x = "Attitude towards buying fast tech",
y = "Probability of belonging to the buyer group"
)Here’s a split by age to identify profiles associated with each concrete disposal method.
disposal_action_profile_data <-
disposal_pathway_outcome_data |>
left_join(
comparative_profile_data |>
select(
respondent_id,
age,
attitude_score,
injunctive_score,
analysis_weight
),
by = "respondent_id"
)
disposal_action_trees <- imap(
disposal_pathway_labels,
~ fit_action_tree(
disposal_action_profile_data,
outcome = .y,
seed = 20260810 + match(
.y,
names(disposal_pathway_labels)
)
)
)
disposal_action_tree_profiles <- imap_dfr(
disposal_action_trees,
~ .x$profiles |>
mutate(
Pathway = unname(disposal_pathway_labels[.y]),
Profile_text = as.character(Profile)
)
) |>
arrange(Pathway, outcome_rate) |>
mutate(
Profile_plot = paste(
Pathway,
Profile_text,
sep = "___"
),
Profile_plot = factor(
Profile_plot,
levels = unique(Profile_plot)
),
Pathway = factor(
Pathway,
levels = c(
"Recycling centre",
"In-store recycling",
"Household waste",
"Plastic-waste bin",
"Keeping devices"
)
)
)
disposal_action_tree_extremes <-
disposal_action_tree_profiles |>
group_by(Pathway) |>
summarise(
lowest_rate = min(outcome_rate),
highest_rate = max(outcome_rate),
.groups = "drop"
)
disposal_recycling_centre_tree_extreme <-
disposal_action_tree_extremes |>
filter(Pathway == "Recycling centre")
disposal_recycling_centre_tree_low_profile <-
disposal_action_tree_profiles |>
filter(Pathway == "Recycling centre") |>
slice_min(outcome_rate, n = 1, with_ties = FALSE)
disposal_recycling_centre_tree_high_profile <-
disposal_action_tree_profiles |>
filter(Pathway == "Recycling centre") |>
slice_max(outcome_rate, n = 1, with_ties = FALSE)
disposal_kept_tree_high_profile <-
disposal_action_tree_profiles |>
filter(Pathway == "Keeping devices") |>
slice_max(outcome_rate, n = 1, with_ties = FALSE)Note: These profiles are exploratory and should be tested before use.
disposal_action_tree_profiles |>
ggplot(aes(
x = outcome_rate,
y = Profile_plot,
fill = Pathway
)) +
geom_col(
width = bg_bar_width,
show.legend = FALSE
) +
geom_text(
aes(label = percent(
outcome_rate,
accuracy = 0.1
)),
hjust = -0.12,
size = bg_size_label / .pt
) +
scale_fill_manual(
values = c(
"Recycling centre" = bg_accent,
"In-store recycling" = bg_accent_50,
"Household waste" = bg_pink,
"Plastic-waste bin" = bg_pink_50,
"Keeping devices" = bg_ink_54
)
) +
scale_x_continuous(
labels = percent_format(),
limits = c(0, 1),
expand = expansion(mult = c(0, 0.12))
) +
scale_y_discrete(
labels = \(x) x |>
str_remove("^[^_]+___") |>
str_wrap(width = 38)
) +
facet_wrap(
~Pathway,
ncol = 1,
scales = "free_y"
) +
labs(
x = "Observed share selecting the method",
y = NULL
)Every tree is restricted to two levels and requires each terminal profile to contain at least 5% of its complete-case sample. This favours readable and adequately sized profiles. Split points remain sample-dependent, and the five trees should be validated on new data before being used for targeting.
| Check | Result |
|---|---|
| Keeping devices | |
| Complete cases | 9,154 |
| Terminal profiles | 2 |
| Maximum tree depth | 1 |
| Smallest profile | 2,227 |
| Cross-validation | Used to choose tree size |
| Household waste | |
| Complete cases | 9,154 |
| Terminal profiles | 4 |
| Maximum tree depth | 2 |
| Smallest profile | 1,561 |
| Cross-validation | Used to choose tree size |
| Plastic-waste bin | |
| Complete cases | 8,266 |
| Terminal profiles | 2 |
| Maximum tree depth | 1 |
| Smallest profile | 1,097 |
| Cross-validation | Used to choose tree size |
| Recycling centre | |
| Complete cases | 9,154 |
| Terminal profiles | 3 |
| Maximum tree depth | 2 |
| Smallest profile | 1,083 |
| Cross-validation | Used to choose tree size |
| In-store recycling | |
| Complete cases | 9,154 |
| Terminal profiles | 1 |
| Maximum tree depth | 0 |
| Smallest profile | 9,154 |
| Cross-validation | Used to choose tree size |
charger_disposal_analysis_data <- df |>
mutate(
respondent_id = row_number(),
charger_disposed = as.character(
.data[[
"12months_disposed_Charging & lighting devices"
]]
) %in% c("2", "3", "4", "5", "6")
) |>
inner_join(
disposal_response_data |>
select(respondent_id),
by = "respondent_id"
) |>
filter(charger_disposed)
method_cols_for_matrix <- disposal_method_cols[
!disposal_method_cols %in% c(
"Other...124",
"I don’t remember...125"
)
]
motivation_cols_for_matrix <- disposal_motivation_cols[
disposal_motivation_cols != "Other...132"
]
method_labels <- c(
"I kept them" = "Kept",
"I threw them in general household waste" =
"Household waste",
"I threw them in the plastic waste bin" =
"Plastic-waste bin",
"I threw them in a street bin" = "Street bin",
"I brought them to a recycling centre / municipal waste facility" =
"Recycling centre",
"I brought them to a recycling point in a store" =
"In-store recycling",
"I gave them away or donated" = "Donated",
"I sold them" = "Sold"
)
motivation_labels <- c(
"It’s the easiest solution" = "Easiest solution",
"It’s closest to home" = "Closest to home",
"It’s what I usually do with this kind of waste" =
"Usual practice",
"I did it to protect the environment" =
"Environmental protection",
"It’s what my family and friends do with that kind of waste" =
"Family and friends",
"I didn’t really think about it" = "Did not think",
"didn’t know where else to put it" =
"Did not know where else"
)
method_motivation_matrix <- map_dfr(
method_cols_for_matrix,
function(method) {
map_dfr(
motivation_cols_for_matrix,
function(motivation) {
pair <- charger_disposal_analysis_data |>
filter(
!is.na(.data[[method]]),
!is.na(.data[[motivation]])
) |>
transmute(
analysis_weight,
country = factor(
Country,
levels = model_country_levels
),
method_selected = is_selected(
.data[[method]]
),
motivation_selected = is_selected(
.data[[motivation]]
)
)
a <- weighted_sum_safe(
pair$method_selected & pair$motivation_selected,
pair$analysis_weight
)
b <- weighted_sum_safe(
pair$method_selected & !pair$motivation_selected,
pair$analysis_weight
)
pair_design <- make_analysis_design(pair)
association_model <- survey::svyglm(
motivation_selected ~ method_selected + country,
design = pair_design,
family = quasibinomial()
)
association_test <- survey::regTermTest(
association_model,
~method_selected,
method = "Wald"
)
method_coefficient <- coef(
association_model
)[["method_selectedTRUE"]]
tibble(
Method = method,
Motivation = motivation,
denominator = nrow(pair),
conditional_pct = a / (a + b),
odds_ratio = exp(method_coefficient),
p_value = as.numeric(association_test$p)
)
}
)
}
) |>
mutate(
q_value = p.adjust(
p_value,
method = "BH"
),
Method = factor(
unname(method_labels[Method]),
levels = rev(unname(method_labels))
),
Motivation = factor(
unname(motivation_labels[Motivation]),
levels = unname(motivation_labels)
),
log2_or = log2(odds_ratio),
label = percent(
conditional_pct,
accuracy = 1
)
)
method_motivation_key <- method_motivation_matrix |>
filter(q_value < 0.05) |>
slice_max(
abs(log2_or),
n = 1,
with_ties = FALSE
)Each cell shows the share of people who gave the reason after choosing the method on that row. Green means the reason is more common for that method, pink means it is less common, and white means little difference. Bold percentages mark links that are unlikely to be due to chance. The comparison also allows for differences between countries.
method_motivation_matrix |>
ggplot(aes(
x = Motivation,
y = Method,
fill = log2_or
)) +
geom_tile(
colour = bg_surface,
linewidth = 1
) +
geom_text(
aes(
label = label,
fontface = if_else(
q_value < 0.05,
"bold",
"plain"
)
),
colour = bg_ink,
size = (bg_size_label - 1) / .pt
) +
scale_fill_gradient2(
low = bg_pink,
mid = bg_surface,
high = bg_accent,
midpoint = 0,
limits = log2(c(0.25, 4)),
oob = scales::squish,
breaks = log2(c(0.25, 1, 4)),
labels = c(
"Less",
"Similar",
"More"
),
name = "Link"
) +
labs(
x = "Reason for choosing the solution",
y = NULL
) +
theme(
axis.text.x = element_text(
angle = 35,
hjust = 1
),
panel.grid = element_blank(),
legend.position = "top"
)Official recycling channels are mainly associated with established habits and environmental protection, whereas inappropriate disposal methods or keeping devices are more commonly associated with convenience, proximity or a lack of forethought.
attitude_response_levels <- c(
"Completely unfavourable",
"Rather unfavourable",
"Neither favourable nor unfavourable",
"Rather favourable",
"Completely favourable"
)
injunctive_response_levels <- c(
"Completely disapprove",
"Rather disapprove",
"Neither approve nor disapprove",
"Rather approve",
"Completely approve"
)
attitude_flow_labels <- c(
"Completely\nunfavourable",
"Rather\nunfavourable",
"Neither favourable\nnor unfavourable",
"Rather\nfavourable",
"Completely\nfavourable"
)
injunctive_flow_labels <- c(
"Completely\ndisapprove",
"Rather\ndisapprove",
"Neither approve\nnor disapprove",
"Rather\napprove",
"Completely\napprove"
)
attitude_injunctive_data <- df |>
transmute(
attitude_score = as.numeric(Attitude_buying_recoded),
injunctive_score = as.numeric(
.data[["Injunctive norm_recoded"]]
),
analysis_weight
) |>
drop_na() |>
mutate(
Attitude = factor(
attitude_score,
levels = 1:5,
labels = attitude_flow_labels
),
`Perceived approval` = factor(
injunctive_score,
levels = 1:5,
labels = injunctive_flow_labels
)
)
attitude_injunctive_rho <- weighted_correlation_safe(
rank(attitude_injunctive_data$attitude_score),
rank(attitude_injunctive_data$injunctive_score),
attitude_injunctive_data$analysis_weight
)
attitude_injunctive_direction <- if_else(
attitude_injunctive_rho >= 0,
"positive",
"negative"
)
attitude_injunctive_strength <- case_when(
abs(attitude_injunctive_rho) < 0.10 ~ "very weak",
abs(attitude_injunctive_rho) < 0.30 ~ "weak",
abs(attitude_injunctive_rho) < 0.50 ~ "moderate",
abs(attitude_injunctive_rho) < 0.70 ~ "strong",
TRUE ~ "very strong"
)
attitude_injunctive_flow <- attitude_injunctive_data |>
group_by(Attitude, `Perceived approval`) |>
summarise(
n = n(),
weighted_n = sum(analysis_weight),
.groups = "drop"
) |>
mutate(pct = weighted_n / sum(weighted_n))Buying attitude and perceived approval show a strong positive association (correlation = 0.58). Respondents who are more favourable towards buying fast tech therefore also tend to perceive greater approval from people important to them.
attitude_injunctive_flow |>
ggplot(aes(
axis1 = Attitude,
axis2 = `Perceived approval`,
y = weighted_n
)) +
ggalluvial::geom_alluvium(
aes(fill = Attitude),
width = 0.24,
alpha = 0.55,
knot.pos = 0.45
) +
ggalluvial::geom_stratum(
width = 0.24,
fill = bg_surface,
colour = bg_ink_32,
linewidth = bg_lw_hairline
) +
ggalluvial::stat_stratum(
geom = "text",
aes(label = after_stat(stratum)),
size = (bg_size_label - 2) / .pt,
lineheight = 0.9
) +
scale_fill_manual(
values = setNames(
bg_ramp("seq", 5),
attitude_flow_labels
),
guide = "none"
) +
scale_x_discrete(
limits = c(
"Buying attitude",
"Perceived approval"
),
expand = expansion(mult = c(0.22, 0.22))
) +
scale_y_continuous(
breaks = NULL,
labels = NULL
) +
labs(
x = NULL,
y = NULL
) +
theme(
panel.grid = element_blank()
)Note: Wider flows mean that the response is more common.
@report{naczaj2026,
author = {Naczaj, Dimitri and Bonetto, Éric},
publisher = {Bellegarde Scientific Consulting for the WEEE Forum},
title = {Fast-Tech {Consumer} {Study}},
date = {2026},
langid = {en-GB}
}
---
title: "Behavioural insight"
subtitle: "WEEE Forum Fast-Tech Consumer Study"
description: |
Behavioural analyses of fast tech purchasing, ownership and end-of-life decisions.
---
```{r}
#| label: setup
#| cache: false
source("R/fast-tech-shared.R")
figure_caption <- function(title, reading_key) {
paste0(
title,
"<br><span class='figure-key'><strong>How to read:</strong> ",
reading_key,
"</span>"
)
}
```
::: executive-summary
## Executive summary {.unnumbered}
This report examines the factors associated with fast tech purchasing, willingness, future intentions and end-of-life methods.
- **Attitude, age and perceived approval stand out.** People with a favourable view of fast tech are more likely to have bought it. The same is true for younger people and those who feel their relatives approve.
- **Durability perceptions matter.** Expected and comparative lifespan are the leading factors associated with willingness to pay more and replacement choices.
- **A shorter perceived lifespan points towards reduction.** It corresponds to lower future purchasing intentions, alongside stronger support for buying fewer items.
- **Several disposal methods coexist.** Recycling is often linked with habit and environmental concern. Keeping devices or putting them in unsuitable bins is more often linked with convenience or not knowing what else to do.
- **Buyer profiles are clear.** Buyers tend to be younger, live in larger households and have a more favourable view of fast tech than non-buyers.
:::
# Research questions {.unnumbered}
1. **Factors influencing buying**
Are buying attitudes, perceived social approval, dynamic norms, durability perceptions and socio-demographic characteristics associated with having bought fast tech?
2. **Durability and future choices**
Which behavioural factors distinguish willingness to pay for longer-lasting alternatives, replacement choices and intentions to increase, reduce or stop future fast-tech purchases?
3. **Exposure and market context**
How do exposure to fast tech, ownership and recent purchasing relate to one another?
4. **End-of-life pathways**
How closely does disposal follow purchasing volume, and which attitudes, norms, lifespan perceptions and declared motivations distinguish keeping, recycling and inappropriate waste disposal?
5. **Communication and policy levers**
Which intersections and respondent profiles offer the clearest opportunities for durability information, purchase-friction messages, take-back communication, collection design and regulatory action?
# Sample subdivisions
```{r}
#| label: sample-subdivisions-data
model_country_levels <- sort(
unique(as.character(df$Country))
)
sociodemo_cross_data <- df |>
transmute(
respondent_id = row_number(),
analysis_weight,
country = factor(
Country,
levels = model_country_levels
),
Age_group = as.character(Age_recoded_1),
Gender_group = recode(
as.character(Gender),
"98" = "No answer"
),
Employment_group = as.character(Employment),
Household_size = as.numeric(Nb_household),
recent_purchase_any_non_vape = if_any(
all_of(recent_purchase_cols),
~ as.character(.x) != "0"
)
)
summarise_binary_outcome_by_group <- function(data, outcome) {
outcome <- enquo(outcome)
summarise_dimension <- function(
variable,
dimension,
levels
) {
data |>
transmute(
Group = as.character(.data[[variable]]),
outcome = as.integer(!!outcome),
analysis_weight
) |>
filter(
!is.na(outcome),
Group %in% levels
) |>
group_by(Group) |>
summarise(
n = n(),
pct = weighted_mean_safe(outcome, analysis_weight),
.groups = "drop"
) |>
mutate(
Dimension = dimension,
Group = factor(
Group,
levels = levels
)
)
}
overall <- data |>
summarise(
pct = weighted_mean_safe(
as.integer(!!outcome),
analysis_weight
)
) |>
pull(pct)
desired_rows <- c(
paste(
"Age",
sociodemographic_age_levels,
sep = "___"
),
paste(
"Gender",
sociodemographic_gender_levels,
sep = "___"
),
paste(
"Employment",
sociodemographic_employment_levels,
sep = "___"
)
)
bind_rows(
summarise_dimension(
"Age_group",
"Age",
sociodemographic_age_levels
),
summarise_dimension(
"Gender_group",
"Gender",
sociodemographic_gender_levels
),
summarise_dimension(
"Employment_group",
"Employment",
sociodemographic_employment_levels
)
) |>
mutate(
Dimension = factor(
Dimension,
levels = c(
"Age",
"Gender",
"Employment"
)
),
Group_plot = paste(
Dimension,
as.character(Group),
sep = "___"
),
Group_plot = factor(
Group_plot,
levels = rev(desired_rows)
),
overall = overall,
comparison = if_else(
pct >= overall,
"At or above overall",
"Below overall"
)
)
}
estimate_household_size_curve <- function(data, outcome) {
outcome <- enquo(outcome)
model_data <- data |>
transmute(
Household_size,
country,
outcome = as.integer(!!outcome),
analysis_weight
) |>
drop_na() |>
droplevels()
model <- survey::svyglm(
outcome ~ Household_size + country,
design = make_analysis_design(model_data),
family = quasibinomial()
)
household_values <- seq(
min(model_data$Household_size),
max(model_data$Household_size),
length.out = 100
)
country_weights <- model_data |>
group_by(country) |>
summarise(country_n = sum(analysis_weight), .groups = "drop") |>
mutate(weight = country_n / sum(country_n))
prediction_data <- tidyr::crossing(
Household_size = household_values,
country = levels(model_data$country)
) |>
mutate(
country = factor(
country,
levels = levels(model_data$country)
)
) |>
left_join(
country_weights,
by = "country"
)
design_matrix <- model.matrix(
delete.response(terms(model)),
data = prediction_data
)
prediction_data$predicted <- plogis(
drop(design_matrix %*% coef(model))
)
map_dfr(
household_values,
function(household_size) {
rows <- which(
prediction_data$Household_size ==
household_size
)
probabilities <- prediction_data$predicted[rows]
weights <- prediction_data$weight[rows]
weighted_derivative <- weights *
probabilities *
(1 - probabilities)
gradient <- colSums(
design_matrix[rows, , drop = FALSE] *
weighted_derivative
)
standard_error <- sqrt(
drop(
t(gradient) %*%
vcov(model) %*%
gradient
)
)
estimate <- sum(weights * probabilities)
tibble(
Household_size = household_size,
pct = estimate,
lower = pmax(
0,
estimate - 1.96 * standard_error
),
upper = pmin(
1,
estimate + 1.96 * standard_error
)
)
}
)
}
summarise_household_size_observed <- function(data, outcome) {
outcome <- enquo(outcome)
data |>
transmute(
Household_size,
outcome = as.integer(!!outcome),
analysis_weight
) |>
drop_na() |>
group_by(Household_size) |>
summarise(
n = n(),
pct = weighted_mean_safe(outcome, analysis_weight),
.groups = "drop"
)
}
buying_sociodemo_summary <- summarise_binary_outcome_by_group(
sociodemo_cross_data,
recent_purchase_any_non_vape
)
buying_household_curve <- estimate_household_size_curve(
sociodemo_cross_data,
recent_purchase_any_non_vape
)
buying_household_observed <- summarise_household_size_observed(
sociodemo_cross_data,
recent_purchase_any_non_vape
)
buying_age_young <- buying_sociodemo_summary |>
filter(
Dimension == "Age",
Group == "18-29"
) |>
pull(pct)
buying_age_old <- buying_sociodemo_summary |>
filter(
Dimension == "Age",
Group == "65-99"
) |>
pull(pct)
buying_employment_high <- buying_sociodemo_summary |>
filter(Dimension == "Employment") |>
slice_max(
pct,
n = 1,
with_ties = FALSE
)
buying_employment_low <- buying_sociodemo_summary |>
filter(Dimension == "Employment") |>
slice_min(
pct,
n = 1,
with_ties = FALSE
)
household_volume_cols <- c(
"Household_Charging & lighting devices",
"Household_High-tech",
"Household_Gadgets & lifestyle items"
)
purchase_band_midpoint <- c(
"0" = 0,
"1" = 1,
"2 to 5" = 3.5,
"6 to 9" = 7.5,
"10 to 19" = 14.5,
"20+" = 20
)
disposal_band_midpoint <- c(
"1" = 0,
"2" = 1,
"3" = 3.5,
"4" = 7.5,
"5" = 14.5,
"6" = 20
)
purchase_disposal_data <- df |>
transmute(
analysis_weight,
purchased_count = rowSums(
across(
all_of(recent_purchase_cols),
~ unname(
purchase_band_midpoint[as.character(.x)]
)
)
),
disposed_count = rowSums(
across(
all_of(disposed_non_vape_cols),
~ unname(
disposal_band_midpoint[as.character(.x)]
)
)
)
) |>
drop_na()
purchase_disposal_rho <- weighted_correlation_safe(
rank(purchase_disposal_data$purchased_count),
rank(purchase_disposal_data$disposed_count),
purchase_disposal_data$analysis_weight
)
purchase_disposal_plot_limits <- c(
purchased = unname(quantile(
purchase_disposal_data$purchased_count,
0.98
)),
disposed = unname(quantile(
purchase_disposal_data$disposed_count,
0.98
))
)
purchase_disposal_plot_eligible <- purchase_disposal_data |>
filter(
purchased_count <= purchase_disposal_plot_limits[["purchased"]],
disposed_count <= purchase_disposal_plot_limits[["disposed"]]
)
set.seed(20260805)
purchase_disposal_plot_data <- purchase_disposal_plot_eligible |>
slice_sample(
n = min(
2500,
nrow(purchase_disposal_plot_eligible)
)
)
purchase_disposal_loess <- loess(
disposed_count ~ purchased_count,
data = purchase_disposal_plot_eligible,
weights = analysis_weight,
span = 0.8,
control = loess.control(surface = "direct")
)
purchase_disposal_gap_data <- tibble(
purchased_count = seq(
0,
min(purchase_disposal_plot_limits),
length.out = 200
)
) |>
mutate(
observed = pmax(
0,
predict(
purchase_disposal_loess,
newdata = pick(purchased_count)
)
),
benchmark = purchased_count,
lower = pmin(observed, benchmark),
upper = pmax(observed, benchmark)
) |>
drop_na()
country_purchase_summary <- df |>
group_by(Country) |>
summarise(
n = n(),
pct_recent_buyer = weighted_mean_safe(
Buy_12months == "Buyer",
analysis_weight
),
.groups = "drop"
) |>
arrange(desc(pct_recent_buyer))
country_ever_bought_summary <- df |>
group_by(Country) |>
summarise(
n = n(),
pct_ever_bought = weighted_mean_safe(
Buyer == "Buyer",
analysis_weight
),
.groups = "drop"
) |>
arrange(desc(pct_ever_bought))
country_ever_bought_high <- country_ever_bought_summary |>
slice_max(
pct_ever_bought,
n = 1,
with_ties = FALSE
)
country_ever_bought_low <- country_ever_bought_summary |>
slice_min(
pct_ever_bought,
n = 1,
with_ties = FALSE
)
country_recent_purchase_high <- country_purchase_summary |>
slice_max(
pct_recent_buyer,
n = 1,
with_ties = FALSE
)
country_recent_purchase_low <- country_purchase_summary |>
slice_min(
pct_recent_buyer,
n = 1,
with_ties = FALSE
)
country_buying_stage_summary <- country_ever_bought_summary |>
select(
Country,
`Ever bought` = pct_ever_bought
) |>
left_join(
country_purchase_summary |>
select(
Country,
`Purchased during the last 12 months` =
pct_recent_buyer
),
by = "Country"
) |>
pivot_longer(
-Country,
names_to = "Measure",
values_to = "pct"
) |>
mutate(
Measure = factor(
Measure,
levels = c(
"Purchased during the last 12 months",
"Ever bought"
)
)
)
country_category_owned_summary <- df |>
select(
Country,
analysis_weight,
all_of(household_volume_cols)
) |>
pivot_longer(
all_of(household_volume_cols),
names_to = "Category",
values_to = "Band"
) |>
filter(!is.na(Band)) |>
mutate(
Category = str_remove(
Category,
"^Household_"
),
owned = as.character(Band) != "0"
) |>
group_by(
Country,
Category
) |>
summarise(
n = n(),
pct = weighted_mean_safe(
owned,
analysis_weight
),
.groups = "drop"
) |>
group_by(Country) |>
mutate(
country_mean = mean(pct)
) |>
ungroup()
country_category_owned_extremes <-
country_category_owned_summary |>
group_by(Category) |>
summarise(
highest_country = Country[which.max(pct)],
highest_pct = max(pct),
lowest_country = Country[which.min(pct)],
lowest_pct = min(pct),
.groups = "drop"
)
extract_country_owned_extreme <- function(
category,
field
) {
country_category_owned_extremes |>
filter(Category == category) |>
pull(all_of(field))
}
country_category_disposed_summary <- df |>
select(
Country,
analysis_weight,
all_of(disposed_non_vape_cols)
) |>
pivot_longer(
all_of(disposed_non_vape_cols),
names_to = "Category",
values_to = "Code"
) |>
filter(!is.na(Code)) |>
mutate(
Category = str_remove(
Category,
"^12months_disposed_"
),
Category = recode(
Category,
"Gadget and lifestyle items" =
"Gadgets & lifestyle items"
),
disposed = as.character(Code) != "1"
) |>
group_by(
Country,
Category
) |>
summarise(
n = n(),
pct = weighted_mean_safe(
disposed,
analysis_weight
),
.groups = "drop"
) |>
group_by(Country) |>
mutate(
country_mean = mean(pct)
) |>
ungroup()
country_category_disposed_extremes <-
country_category_disposed_summary |>
group_by(Category) |>
summarise(
highest_country = Country[which.max(pct)],
highest_pct = max(pct),
lowest_country = Country[which.min(pct)],
lowest_pct = min(pct),
.groups = "drop"
)
extract_country_disposed_extreme <- function(
category,
field
) {
country_category_disposed_extremes |>
filter(Category == category) |>
pull(all_of(field))
}
country_category_purchase_summary <- df |>
select(
Country,
analysis_weight,
all_of(recent_purchase_cols)
) |>
pivot_longer(
all_of(recent_purchase_cols),
names_to = "Category",
values_to = "Band"
) |>
mutate(
Category = str_remove(
Category,
"^Buy_12months_"
),
bought = as.character(Band) != "0"
) |>
group_by(
Country,
Category
) |>
summarise(
n = sum(!is.na(bought)),
pct = weighted_mean_safe(bought, analysis_weight),
.groups = "drop"
) |>
group_by(Country) |>
mutate(
country_mean = mean(pct)
) |>
ungroup()
country_category_purchase_extremes <-
country_category_purchase_summary |>
group_by(Category) |>
summarise(
highest_country = Country[which.max(pct)],
highest_pct = max(pct),
lowest_country = Country[which.min(pct)],
lowest_pct = min(pct),
.groups = "drop"
)
extract_country_purchase_extreme <- function(category, field) {
country_category_purchase_extremes |>
filter(Category == category) |>
pull(all_of(field))
}
country_category_ownership_purchase_summary <- bind_rows(
country_category_owned_summary |>
transmute(
Country,
Category,
Measure = "Owned at home",
pct
),
country_category_purchase_summary |>
transmute(
Country,
Category,
Measure = "Purchased during the last 12 months",
pct
)
) |>
left_join(
country_category_owned_summary |>
distinct(
Country,
country_mean
),
by = "Country"
) |>
mutate(
Measure = factor(
Measure,
levels = c(
"Purchased during the last 12 months",
"Owned at home"
)
)
)
country_disposal_method_summary <- df |>
select(
Country,
analysis_weight,
all_of(disposal_method_cols)
) |>
pivot_longer(
all_of(disposal_method_cols),
names_to = "Method",
values_to = "Value"
) |>
filter(!is.na(Value)) |>
mutate(
Method = recode(
Method,
"Other...124" = "Other",
"I don’t remember...125" =
"I don’t remember"
),
selected = is_selected(Value),
method_type = case_when(
Method %in% c(
"I brought them to a recycling centre / municipal waste facility",
"I brought them to a recycling point in a store",
"I gave them away or donated",
"I sold them"
) ~ "Recycling or reuse",
Method %in% c(
"I threw them in general household waste",
"I threw them in the plastic waste bin",
"I threw them in a street bin"
) ~ "Waste disposal",
TRUE ~ "Kept, other or unknown"
)
) |>
group_by(
Country,
Method,
method_type
) |>
summarise(
n = sum(selected),
denominator = n(),
pct = weighted_mean_safe(selected, analysis_weight),
.groups = "drop"
)
country_disposal_method_levels <- c(
"I brought them to a recycling centre / municipal waste facility",
"I brought them to a recycling point in a store",
"I gave them away or donated",
"I sold them",
"I kept them",
"I threw them in general household waste",
"I threw them in the plastic waste bin",
"I threw them in a street bin",
"I don’t remember",
"Other"
)
recycling_centre_method <-
"I brought them to a recycling centre / municipal waste facility"
household_waste_method <-
"I threw them in general household waste"
country_disposal_method_extremes <-
country_disposal_method_summary |>
group_by(Method) |>
summarise(
highest_country = Country[which.max(pct)],
highest_pct = max(pct),
lowest_country = Country[which.min(pct)],
lowest_pct = min(pct),
.groups = "drop"
)
extract_country_disposal_extreme <- function(method, field) {
country_disposal_method_extremes |>
filter(Method == method) |>
pull(all_of(field))
}
test_country_disposal_difference <- function(method, label) {
test_data <- df |>
transmute(
Country = factor(Country),
analysis_weight,
response = .data[[method]]
) |>
filter(!is.na(response)) |>
mutate(
selected = factor(
is_selected(response),
levels = c(FALSE, TRUE)
)
)
design <- survey::svydesign(
ids = ~1,
weights = ~analysis_weight,
data = test_data
)
test <- survey::svychisq(
~selected + Country,
design = design,
statistic = "F"
)
tibble(
Method = label,
F = unname(test$statistic),
df_num = unname(test$parameter[[1]]),
df_den = unname(test$parameter[[2]]),
p_value = test$p.value
)
}
country_disposal_tests <- bind_rows(
test_country_disposal_difference(
recycling_centre_method,
"Recycling centre"
),
test_country_disposal_difference(
household_waste_method,
"Household waste"
)
)
recycling_country_test <- country_disposal_tests |>
filter(Method == "Recycling centre")
household_waste_country_test <- country_disposal_tests |>
filter(Method == "Household waste")
```
## Exposure, ownership and recent purchasing
```{r}
#| label: exposure-volume-associations-data
exposure_score_levels <- c(
"Less or never" = 1,
"Few times a year" = 2,
"At least once a month" = 3,
"At least once a week" = 4,
"Everyday" = 5
)
exposure_volume_data <- df |>
transmute(
analysis_weight,
exposure_score = unname(
exposure_score_levels[as.character(`SeeFast-tech`)]
),
items_at_home = rowSums(
across(
all_of(household_volume_cols),
~ unname(purchase_band_midpoint[as.character(.x)])
)
),
items_purchased = rowSums(
across(
all_of(recent_purchase_cols),
~ unname(purchase_band_midpoint[as.character(.x)])
)
)
) |>
drop_na()
exposure_home_r <- weighted_correlation_safe(
exposure_volume_data$exposure_score,
exposure_volume_data$items_at_home,
exposure_volume_data$analysis_weight
)
exposure_purchase_r <- weighted_correlation_safe(
exposure_volume_data$exposure_score,
exposure_volume_data$items_purchased,
exposure_volume_data$analysis_weight
)
exposure_volume_plot_data <- exposure_volume_data |>
transmute(
analysis_weight,
Exposure = exposure_score,
`Items at home` = items_at_home,
`Items purchased during the last 12 months` = items_purchased
) |>
pivot_longer(
c(`Items at home`, `Items purchased during the last 12 months`),
names_to = "Measure",
values_to = "Estimated_items"
)
exposure_volume_summary <- unique(
exposure_volume_plot_data$Measure
) |>
map_dfr(function(measure) {
measure_data <- exposure_volume_plot_data |>
filter(Measure == measure)
survey::svyby(
~Estimated_items,
~Exposure,
make_analysis_design(measure_data),
survey::svymean,
vartype = "ci",
keep.names = FALSE
) |>
as_tibble() |>
transmute(
Measure = measure,
Exposure,
mean_items = Estimated_items,
conf_low = pmax(0, ci_l),
conf_high = ci_u
)
})
```
Quite logically, the more respondents report to be exposed to fast tech, the more devices they have at home and the more they've bought during the last 12 months. We cannot know the direction of the relationship, though.
Do they buy more because they are more frequently exposed? Do they remember or notice more being exposed because they buy or own more? Do we notice fast tech more if we own more of them?
```{r}
#| label: fig-exposure-volume-associations
#| fig-cap: !expr figure_caption("Exposure to fast tech and estimated item volumes", "Higher exposure frequency accompanies both greater ownership and more purchases, without establishing which comes first.")
#| fig-height: 7
exposure_volume_summary |>
ggplot(aes(
x = Exposure,
y = mean_items,
colour = Measure,
group = Measure
)) +
geom_errorbar(
aes(
ymin = conf_low,
ymax = conf_high
),
width = 0.08,
linewidth = bg_lw_hairline,
position = position_dodge(width = 0.16)
) +
geom_line(
linewidth = bg_lw_line,
position = position_dodge(width = 0.16)
) +
geom_point(
size = bg_point_size,
position = position_dodge(width = 0.16)
) +
scale_colour_manual(
values = c(
"Items at home" = bg_accent,
"Items purchased during the last 12 months" = bg_pink
),
name = NULL
) +
scale_x_continuous(
breaks = 1:5,
labels = c(
"Less or\nnever",
"A few times\na year",
"Monthly",
"Weekly",
"Every day"
)
) +
labs(
x = "Reported exposure frequency",
y = "Estimated number of items"
) +
theme(
legend.position = "top"
)
```
## Ever having bought fast tech & recent purchasing, by country
The share of respondents who have ever bought fast tech ranges from
**`r country_ever_bought_low$Country[[1]]`
(`r format_percent(country_ever_bought_low$pct_ever_bought[[1]])`)** to
**`r country_ever_bought_high$Country[[1]]`
(`r format_percent(country_ever_bought_high$pct_ever_bought[[1]])`)**. Recent
purchasing ranges from **`r country_recent_purchase_low$Country[[1]]`
(`r format_percent(country_recent_purchase_low$pct_recent_buyer[[1]])`)** to
**`r country_recent_purchase_high$Country[[1]]`
(`r format_percent(country_recent_purchase_high$pct_recent_buyer[[1]])`)**.
```{r}
#| label: fig-ever-recent-purchase-country
#| fig-cap: !expr figure_caption("Ever having bought and recent purchasing, by country", paste0("In ", country_ever_bought_high$Country[[1]], ", ", format_percent(country_ever_bought_high$pct_ever_bought[[1]]), " have bought fast tech at least once, while ", format_percent(country_purchase_summary$pct_recent_buyer[country_purchase_summary$Country == country_ever_bought_high$Country[[1]]][[1]]), " purchased it during the previous 12 months."))
#| fig-height: 7.5
country_buying_stage_summary |>
left_join(
country_ever_bought_summary |>
select(
Country,
country_order = pct_ever_bought
),
by = "Country"
) |>
mutate(
Country = fct_reorder(
Country,
country_order
)
) |>
ggplot(aes(
x = pct,
y = Country,
fill = Measure
)) +
geom_col(
position = position_dodge(
width = 0.76
),
width = 0.66
) +
geom_text(
aes(label = percent(
pct,
accuracy = 0.1
)),
position = position_dodge(
width = 0.76
),
hjust = -0.15,
size = bg_size_label / .pt
) +
scale_fill_manual(
name = NULL,
breaks = c(
"Ever bought",
"Purchased during the last 12 months"
),
values = c(
"Ever bought" = bg_accent,
"Purchased during the last 12 months" =
bg_accent_50
)
) +
scale_x_continuous(
labels = percent_format(),
limits = c(0, 1),
expand = expansion(
mult = c(0, 0.06)
)
) +
labs(
x = "% of respondents",
y = NULL
) +
theme(
legend.position = "top"
)
```
## Recent purchasing by socio-demographic characteristics
Recent purchasing changes with age. It decreases from **`r format_percent(buying_age_young)` among younger respondents (18–29)** to **`r format_percent(buying_age_old)` among those aged 65–99**. By employment status, it is highest among `r tolower(as.character(buying_employment_high$Group))` (`r format_percent(buying_employment_high$pct)`) and lowest among `r tolower(as.character(buying_employment_low$Group))` (`r format_percent(buying_employment_low$pct)`).
```{r}
#| label: fig-buying-sociodemo
#| fig-cap: !expr figure_caption("Purchase of at least one non-vape fast tech item during the last 12 months, by socio-demographic group", paste0(format_percent(buying_age_young), " of respondents aged 18–29 purchased at least one item, compared with ", format_percent(buying_age_old), " of those aged 65–99."))
#| fig-height: 8
buying_sociodemo_summary |>
ggplot(aes(
x = pct,
y = Group_plot,
colour = comparison
)) +
geom_vline(
xintercept = buying_sociodemo_summary$overall[[1]],
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_segment(
aes(
x = overall,
xend = pct,
yend = Group_plot
),
colour = bg_ink_32,
linewidth = bg_lw_line
) +
geom_point(size = 3) +
geom_text(
aes(label = percent(pct, accuracy = 0.1)),
hjust = -0.35,
colour = bg_ink,
size = bg_size_label / .pt
) +
scale_colour_manual(
values = c(
"At or above overall" = bg_accent,
"Below overall" = bg_blue
),
guide = "none"
) +
scale_x_continuous(
labels = percent_format(),
limits = c(0, 1),
expand = expansion(mult = c(0, 0.06))
) +
scale_y_discrete(
labels = \(x) str_remove(x, "^.*___")
) +
facet_grid(
rows = vars(Dimension),
scales = "free_y",
space = "free_y"
) +
labs(
x = "% purchasing at least one item",
y = NULL
) +
theme(
strip.text.y = element_text(angle = 0)
)
```
## Recent purchasing by household size
The curve shows the estimated probability of purchasing, with observed rates as points.
```{r}
#| label: fig-buying-household-size
#| fig-cap: !expr figure_caption("Recent purchase of non-vape fast tech by household size", paste0(format_percent(buying_household_observed$pct[buying_household_observed$Household_size == 2][[1]]), " of respondents in two-person households purchased at least one item."))
ggplot(
buying_household_curve,
aes(x = Household_size, y = pct)
) +
geom_ribbon(
aes(ymin = lower, ymax = upper),
fill = bg_accent_20,
alpha = bg_alpha_mute
) +
geom_line(
colour = bg_accent,
linewidth = bg_lw_line
) +
geom_point(
data = buying_household_observed,
aes(size = n),
colour = bg_ink
) +
scale_size_continuous(
name = "N",
range = c(2, 5)
) +
scale_x_continuous(
breaks = 1:10
) +
scale_y_continuous(
labels = percent_format(),
limits = c(0, 1),
expand = expansion(mult = c(0, 0.04))
) +
labs(
x = "Household size",
y = "Estimated probability of recent purchase"
)
```
## Ownership and recent purchasing by category and country
Ownership and recent purchasing follow similar country patterns, but ownership
is consistently more frequent because it includes devices acquired before the
last 12 months. For charging and lighting devices, ownership ranges from
**`r extract_country_owned_extreme("Charging & lighting devices", "lowest_country")`
(`r format_percent(extract_country_owned_extreme("Charging & lighting devices", "lowest_pct"))`)**
to **`r extract_country_owned_extreme("Charging & lighting devices", "highest_country")`
(`r format_percent(extract_country_owned_extreme("Charging & lighting devices", "highest_pct"))`)**,
while recent purchasing ranges from
**`r extract_country_purchase_extreme("Charging & lighting devices", "lowest_country")`
(`r format_percent(extract_country_purchase_extreme("Charging & lighting devices", "lowest_pct"))`)**
to **`r extract_country_purchase_extreme("Charging & lighting devices", "highest_country")`
(`r format_percent(extract_country_purchase_extreme("Charging & lighting devices", "highest_pct"))`)**.
```{r}
#| label: fig-ownership-purchase-category-country
#| fig-cap: !expr figure_caption("Ownership and recent purchasing, by category and country", paste0("In ", extract_country_owned_extreme("Charging & lighting devices", "highest_country"), ", ", format_percent(extract_country_owned_extreme("Charging & lighting devices", "highest_pct")), " report owning a charging or lighting device at home."))
#| fig-height: 14
country_category_ownership_purchase_summary |>
mutate(
Country = fct_reorder(
Country,
country_mean
),
Category = factor(
Category,
levels = c(
"Charging & lighting devices",
"High-tech",
"Gadgets & lifestyle items"
)
)
) |>
ggplot(aes(
x = pct,
y = Country,
fill = Measure
)) +
geom_col(
position = position_dodge(
width = 0.76
),
width = 0.66
) +
geom_text(
aes(label = percent(
pct,
accuracy = 0.1
)),
position = position_dodge(
width = 0.76
),
hjust = -0.15,
size = bg_size_label / .pt
) +
scale_fill_manual(
name = NULL,
breaks = c(
"Owned at home",
"Purchased during the last 12 months"
),
values = c(
"Owned at home" = bg_accent,
"Purchased during the last 12 months" =
bg_accent_50
)
) +
scale_x_continuous(
labels = percent_format(),
limits = c(0, 1),
expand = expansion(
mult = c(0, 0.07)
)
) +
facet_wrap(
~Category,
ncol = 1
) +
labs(
x = "% of respondents",
y = NULL
) +
theme(
legend.position = "top",
panel.spacing.y = grid::unit(
1.2,
"lines"
)
)
```
> **Note:** Percentages use the global analysis weight and are calculated separately within each country and category. Ownership refers to devices present in the household and may include devices used by other household members.
## Buying reasons and willingness to pay more
```{r}
#| label: buying-reasons-pay-more-data
buying_reason_cols <- c(
"They are more affordable",
"They are the only available option",
"They are easily available or showcased",
"I read positive reviews about them",
"Promotions or special deals",
"People I trust recommend them",
"I see them in ads or on social media",
"Curiosity, for fun, or trying something new"
)
buying_reason_pay_more_summary <- df |>
select(
analysis_weight,
`Pay more`,
all_of(buying_reason_cols)
) |>
filter(!is.na(`Pay more`)) |>
pivot_longer(
all_of(buying_reason_cols),
names_to = "Reason",
values_to = "Value"
) |>
filter(!is.na(Value)) |>
mutate(
selected = is_selected(Value),
Willingness = factor(
`Pay more`,
levels = willingness_to_pay_levels
)
) |>
group_by(Willingness, Reason) |>
summarise(
n = sum(selected),
denominator = n(),
pct = weighted_mean_safe(
selected,
analysis_weight
),
.groups = "drop"
) |>
mutate(
Reason_plot = str_wrap(Reason, width = 34)
)
buying_reason_pay_more_extreme <-
buying_reason_pay_more_summary |>
slice_max(pct, n = 1, with_ties = FALSE)
if (!requireNamespace("nnet", quietly = TRUE)) {
stop("Install the 'nnet' package before rendering this section.")
}
buying_reason_wtp_model_data <- df |>
transmute(
willingness = factor(
as.character(`Pay more`),
levels = willingness_to_pay_levels,
ordered = FALSE
),
affordability = as.integer(
is_selected(`They are more affordable`)
),
promotions = as.integer(
is_selected(`Promotions or special deals`)
),
analysis_weight
) |>
drop_na()
buying_reason_wtp_fit_object <- fit_survey_multinomial(
willingness ~ affordability + promotions,
data = buying_reason_wtp_model_data
)
buying_reason_wtp_model <- buying_reason_wtp_fit_object$model
buying_reason_wtp_vcov <- buying_reason_wtp_fit_object$vcov
buying_reason_wtp_fit <- survey_multinomial_joint_test(
buying_reason_wtp_model,
buying_reason_wtp_vcov
) |>
mutate(
N = nrow(buying_reason_wtp_model_data),
.before = 1
)
buying_reason_wtp_predictor_tests <-
survey_multinomial_term_tests(
buying_reason_wtp_model,
buying_reason_wtp_vcov,
c("affordability", "promotions")
) |>
mutate(
Predictor = recode(
term,
affordability = "Affordability",
promotions = "Promotions or special deals"
)
) |>
select(Predictor, Wald, df, p_value)
average_buying_reason_wtp_probabilities <- function(
variable,
value
) {
new_data <- buying_reason_wtp_model_data
new_data[[variable]] <- value
predictions <- predict(
buying_reason_wtp_model,
newdata = new_data,
type = "probs"
)
colSums(
predictions *
buying_reason_wtp_model_data$analysis_weight
) / sum(buying_reason_wtp_model_data$analysis_weight)
}
buying_reason_wtp_effects <- imap_dfr(
c(
affordability = "Affordability",
promotions = "Promotions or special deals"
),
function(label, variable) {
difference <-
average_buying_reason_wtp_probabilities(variable, 1) -
average_buying_reason_wtp_probabilities(variable, 0)
tibble(
Predictor = label,
Willingness = names(difference),
probability_difference = as.numeric(difference)
)
}
)
affordability_some_effect <- buying_reason_wtp_effects |>
filter(
Predictor == "Affordability",
Willingness == "Yes, for some of them"
) |>
pull(probability_difference)
affordability_all_effect <- buying_reason_wtp_effects |>
filter(
Predictor == "Affordability",
Willingness == "Yes, for all of them"
) |>
pull(probability_difference)
```
**Is there a link between the reasons respondents have to buy fast tech and their willingness to pay more for a more durable device?**
Affordability is the clearest factor. People who mention low prices as a reason for buying fast tech are `r percent(abs(affordability_some_effect), accuracy = 0.1)` more likely to say they would pay more for only some longer-lasting devices, and `r percent(abs(affordability_all_effect), accuracy = 0.1)` less likely to say they would pay more for all of them. Promotions do not show a clear link once affordability is taken into account.
```{r}
#| label: fig-buying-reasons-pay-more
#| fig-cap: !expr figure_caption("Buying reasons by willingness to pay more for longer-lasting alternatives", paste0(format_percent(buying_reason_pay_more_extreme$pct), " of respondents answering ‘", buying_reason_pay_more_extreme$Willingness, "’ selected ‘", buying_reason_pay_more_extreme$Reason, "’."))
#| fig-height: 7.2
buying_reason_pay_more_summary |>
ggplot(aes(
x = Willingness,
y = fct_rev(factor(
Reason_plot,
levels = unique(str_wrap(
buying_reason_cols,
width = 34
))
)),
fill = pct
)) +
geom_tile(
colour = bg_surface,
linewidth = bg_lw_hairline
) +
geom_text(
aes(label = percent(pct, accuracy = 0.1)),
size = (bg_size_label - 1) / .pt,
colour = bg_ink
) +
scale_fill_gradient(
low = bg_accent_20,
high = bg_accent,
guide = "none"
) +
labs(x = NULL, y = NULL) +
theme(
axis.text.x = element_text(angle = 25, hjust = 1),
panel.grid = element_blank(),
legend.position = "none"
)
```
::: {.callout-note collapse="true" title="Show tables and assumptions"}
The model distinguishes the four willingness responses. The bars show how each buying reason is linked with each response. These links do not prove that one causes the other.
```{r}
#| label: tbl-buying-reasons-wtp-fit
#| cache: false
#| tbl-cap: "Global fit of the buying-reason model"
buying_reason_wtp_fit |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_integer(columns = c(N, df)) |>
fmt_number(
columns = Wald,
decimals = 3
) |>
cols_label(
Wald = "Overall test",
p_value = "p-value"
) |>
gt_bellegarde()
```
```{r}
#| label: tbl-buying-reasons-wtp-tests
#| cache: false
#| tbl-cap: "Contribution of affordability and promotions"
buying_reason_wtp_predictor_tests |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_number(columns = Wald, decimals = 2) |>
cols_label(
Wald = "Overall test",
p_value = "p-value"
) |>
gt_bellegarde()
```
```{r}
#| label: tbl-buying-reasons-wtp-effects
#| cache: false
#| tbl-cap: "Probability differences by willingness response"
buying_reason_wtp_effects |>
gt(groupname_col = "Predictor") |>
fmt_percent(
columns = probability_difference,
decimals = 1
) |>
cols_label(
probability_difference = "Probability difference"
) |>
gt_bellegarde()
```
:::
> **Note:** Q10 allowed multiple responses, percentages within a column therefore do not sum to 100%.
## Purchase channel and reasons for buying
```{r}
#| label: purchase-channel-item-reasons-data
channel_reason_categories <- c(
"Charging & lighting devices",
"High-tech",
"Gadgets & lifestyle items"
)
channel_reason_labels <- c(
"For work, study or everyday use",
"As a temporary replacement",
"I wanted a newer version",
"As a spare device",
"I bought it even though I didn't really need it",
"It was a gift for someone else",
"There was no other product / no other choice"
)
channel_reason_reason_plot_labels <- c(
"For work, study or everyday use" = "Everyday use",
"As a temporary replacement" = "Temporary replacement",
"I wanted a newer version" = "Newer version",
"As a spare device" = "Spare device",
"I bought it even though I didn't really need it" =
"Bought without need",
"It was a gift for someone else" = "Gift for someone else",
"There was no other product / no other choice" =
"No other choice"
)
channel_reason_columns <- unlist(
lapply(
channel_reason_categories,
\(category) paste0(
category,
"_",
channel_reason_labels
)
),
use.names = FALSE
)
channel_reason_channel_labels <- c(
"On the brand’s website",
"Online stores, marketplaces, social media",
"Tobacconist, convenience store",
"Physical stores, outdoor stall, market store",
"At the airport or a train station",
"As gifts, professional giveaways or merch",
"Second hand platform"
)
channel_reason_channel_plot_labels <- c(
"On the brand’s website" = "Brand website",
"Online stores, marketplaces, social media" =
"Online / marketplaces / social media",
"Tobacconist, convenience store" =
"Tobacconist / convenience store",
"Physical stores, outdoor stall, market store" =
"Physical stores / stalls / markets",
"At the airport or a train station" =
"Airport / train station",
"As gifts, professional giveaways or merch" =
"Gifts / giveaways / merch",
"Second hand platform" = "Second-hand platform"
)
channel_reason_responses <- df |>
mutate(respondent_id = row_number()) |>
select(
respondent_id,
analysis_weight,
all_of(channel_reason_columns)
) |>
pivot_longer(
cols = all_of(channel_reason_columns),
names_to = "Variable",
values_to = "Value"
) |>
mutate(
Reason = str_remove(Variable, "^[^_]+_"),
reason_presented = !is.na(Value),
reason_selected = is_selected(Value)
) |>
group_by(
respondent_id,
Reason
) |>
summarise(
analysis_weight = first(analysis_weight),
reason_presented = any(reason_presented),
reason_selected = any(reason_selected),
.groups = "drop"
) |>
filter(
reason_presented,
Reason %in% channel_reason_labels
)
channel_reason_channel_responses <- df |>
mutate(respondent_id = row_number()) |>
select(
respondent_id,
all_of(channel_reason_channel_labels)
) |>
pivot_longer(
cols = all_of(channel_reason_channel_labels),
names_to = "Channel",
values_to = "Value"
) |>
filter(!is.na(Value)) |>
transmute(
respondent_id,
Channel,
channel_selected = is_selected(Value)
)
channel_reason_analysis_data <- channel_reason_responses |>
inner_join(
channel_reason_channel_responses,
by = "respondent_id"
)
channel_reason_overall <- channel_reason_analysis_data |>
distinct(
respondent_id,
Reason,
.keep_all = TRUE
) |>
group_by(Reason) |>
summarise(
overall_pct = weighted_mean_safe(
reason_selected,
analysis_weight
),
.groups = "drop"
)
channel_reason_summary <- channel_reason_analysis_data |>
filter(channel_selected) |>
group_by(
Channel,
Reason
) |>
summarise(
n = n(),
pct = weighted_mean_safe(
reason_selected,
analysis_weight
),
.groups = "drop"
) |>
left_join(
channel_reason_overall,
by = "Reason"
) |>
mutate(
difference = pct - overall_pct,
Channel_plot = str_wrap(
unname(channel_reason_channel_plot_labels[Channel]),
width = 18
),
Reason_plot = str_wrap(
unname(channel_reason_reason_plot_labels[Reason]),
width = 18
)
)
channel_reason_difference_limit <- max(
abs(channel_reason_summary$difference)
)
channel_reason_key <- channel_reason_summary |>
slice_max(
abs(difference),
n = 1,
with_ties = FALSE
)
channel_reason_n <- channel_reason_analysis_data |>
distinct(respondent_id) |>
nrow()
```
This comparison combines Q8b/Q9b across the three non-vape fast tech categories. A reason is counted once per respondent, even if it was selected for more than one category. The clearest difference is among respondents who selected “`r channel_reason_key$Channel`” as a usual channel: **`r format_percent(channel_reason_key$pct)` also selected “`r channel_reason_key$Reason`” for at least one category, `r number(abs(channel_reason_key$difference) * 100, accuracy = 0.1)` percentage points above the overall share**.
```{r}
#| label: fig-purchase-channel-item-reasons
#| fig-cap: !expr figure_caption("Reasons for buying fast tech, by usual purchase channel", paste0(format_percent(channel_reason_key$pct), " of respondents selecting ‘", channel_reason_key$Channel, "’ also selected ‘", channel_reason_key$Reason, "’ for at least one category."))
#| fig-height: 7.2
channel_reason_summary |>
mutate(
Channel_plot = factor(
Channel_plot,
levels = str_wrap(
unname(channel_reason_channel_plot_labels),
width = 18
)
),
Reason_plot = factor(
Reason_plot,
levels = str_wrap(
unname(channel_reason_reason_plot_labels),
width = 18
)
)
) |>
ggplot(aes(
x = Reason_plot,
y = fct_rev(Channel_plot),
fill = difference
)) +
geom_tile(
colour = bg_surface,
linewidth = bg_lw_hairline
) +
geom_text(
aes(label = percent(pct, accuracy = 1)),
size = (bg_size_label - 1) / .pt,
colour = bg_ink
) +
scale_fill_gradient2(
low = bg_pink,
mid = bg_surface,
high = bg_accent,
midpoint = 0,
limits = c(
-channel_reason_difference_limit,
channel_reason_difference_limit
),
breaks = c(-0.2, 0, 0.2),
labels = label_percent(accuracy = 1),
name = "Difference\nfrom overall"
) +
labs(
x = NULL,
y = NULL
) +
theme(
axis.text.x = element_text(
angle = 25,
hjust = 1,
lineheight = 0.9
),
panel.grid = element_blank(),
legend.position = "top"
)
```
The percentages show the share of respondents using each channel who selected each reason for at least one category. The colour compares that percentage with the overall share for the same reason. Both questions allowed multiple responses, so the cells show co-occurrence rather than exclusive pathways. The analysis includes `r format_count(channel_reason_n)` respondents with both sets of answers.
## Shortest duration details
```{r}
#| label: shortest-duration-age-data
#| cache: true
shortest_duration_levels <- c(
"I got rid of it without ever using it",
"Single-use",
"Less than a week",
"Less than a month",
"1 to 6 months",
"6 to 12 months",
"12 months or more"
)
shortest_duration_age_data <- df |>
transmute(
Age_group = factor(
as.character(Age_recoded_1),
levels = sociodemographic_age_levels
),
Duration = factor(
as.character(Shortest_used),
levels = shortest_duration_levels,
ordered = TRUE
),
analysis_weight
) |>
drop_na()
shortest_duration_age_summary <-
shortest_duration_age_data |>
group_by(
Age_group,
Duration,
.drop = FALSE
) |>
summarise(
n = n(),
weighted_n = sum(analysis_weight),
.groups = "drop_last"
) |>
mutate(
pct = weighted_n / sum(weighted_n)
) |>
ungroup()
shortest_duration_under_six_by_age <-
shortest_duration_age_data |>
mutate(
no_more_than_six_months =
as.integer(Duration) <= 5
) |>
group_by(Age_group) |>
summarise(
n = n(),
pct = weighted_mean_safe(
no_more_than_six_months,
analysis_weight
),
.groups = "drop"
)
shortest_duration_young_pct <-
shortest_duration_under_six_by_age |>
filter(Age_group == "18-29") |>
pull(pct)
shortest_duration_old_pct <-
shortest_duration_under_six_by_age |>
filter(Age_group == "65-99") |>
pull(pct)
shortest_duration_age_test <- survey::svychisq(
~Age_group + Duration,
design = make_analysis_design(
shortest_duration_age_data
),
statistic = "F"
)
```
Short recalled durations are more frequent among younger respondents. **`r format_percent(shortest_duration_young_pct)` of those aged 18–29 report that their shortest-lived device was used for no more than six months**, compared with **`r format_percent(shortest_duration_old_pct)` of those aged 65–99**. The full distribution differs by age group (`r format_p_value(shortest_duration_age_test$p.value)`).
```{r}
#| label: fig-shortest-duration-age
#| cache: false
#| fig-cap: !expr figure_caption("Shortest recalled duration of use by age group", paste0(format_percent(shortest_duration_young_pct), " of respondents aged 18–29 recalled using their shortest-lived device for no more than six months, compared with ", format_percent(shortest_duration_old_pct), " of those aged 65–99."))
#| fig-height: 5.8
shortest_duration_age_summary |>
ggplot(aes(
x = Duration,
y = fct_rev(Age_group),
fill = pct
)) +
geom_tile(
colour = bg_surface,
linewidth = 1
) +
geom_text(
aes(label = percent(pct, accuracy = 0.1)),
colour = bg_ink,
size = (bg_size_label - 1) / .pt
) +
scale_fill_bg_c(
palette = "seq",
labels = percent_format(),
name = "% within age group"
) +
scale_x_discrete(
labels = \(x) str_wrap(x, width = 17)
) +
labs(
x = "Shortest recalled duration of use",
y = "Age group"
) +
theme(
axis.text.x = element_text(
angle = 30,
hjust = 1
),
panel.grid = element_blank(),
legend.position = "top"
)
```
This result describes each respondent’s shortest recalled experience, not the average lifespan of all their devices. Differences between age groups can also reflect different purchasing histories and recall, so they should not be interpreted as an effect of age alone.
## End-of-life disposal and purchasing volume
Quite logically, end-of-life disposal follows purchasing volume. Respondents reporting more purchases also tend to report more disposal (**correlation = `r number(purchase_disposal_rho, accuracy = 0.01)`**).
```{r}
#| label: fig-purchase-disposal-correlation
#| fig-cap: !expr figure_caption("Estimated number of non-vape fast tech items purchased and disposed of during the last 12 months", "Respondents reporting about 15 items recently bought reported a little less than 10 devices disposed of during the past 12 months")
purchase_disposal_plot_data |>
ggplot(aes(
x = purchased_count,
y = disposed_count
)) +
geom_ribbon(
data = purchase_disposal_gap_data,
aes(
x = purchased_count,
ymin = lower,
ymax = upper
),
inherit.aes = FALSE,
fill = bg_accent_20,
alpha = 0.55
) +
geom_abline(
intercept = 0,
slope = 1,
colour = bg_pink,
linetype = "dashed",
linewidth = bg_lw_line
) +
geom_jitter(
width = 0.55,
height = 0.55,
alpha = 0.16,
size = 1.2,
colour = bg_accent
) +
geom_smooth(
data = purchase_disposal_plot_eligible,
method = "loess",
formula = y ~ x,
span = 0.8,
se = FALSE,
colour = bg_accent,
linewidth = bg_lw_line
) +
scale_x_continuous(
breaks = pretty_breaks(n = 6),
expand = expansion(mult = c(0, 0.01))
) +
scale_y_continuous(
breaks = pretty_breaks(n = 6),
expand = expansion(mult = c(0, 0.01))
) +
coord_cartesian(
xlim = c(
0,
purchase_disposal_plot_limits[["purchased"]]
),
ylim = c(
0,
purchase_disposal_plot_limits[["disposed"]]
)
) +
labs(
x = "Estimated number purchased",
y = "Estimated number disposed of"
) +
theme(
legend.position = "none"
)
```
## Disposal habits by country
End-of-life methods differ clearly between countries. Taking devices to a recycling centre ranges from **`r extract_country_disposal_extreme(recycling_centre_method, "lowest_country")` (`r format_percent(extract_country_disposal_extreme(recycling_centre_method, "lowest_pct"))`)** to **`r extract_country_disposal_extreme(recycling_centre_method, "highest_country")` (`r format_percent(extract_country_disposal_extreme(recycling_centre_method, "highest_pct"))`)**.
Disposal in general household waste ranges from **`r extract_country_disposal_extreme(household_waste_method, "lowest_country")` (`r format_percent(extract_country_disposal_extreme(household_waste_method, "lowest_pct"))`)** to **`r extract_country_disposal_extreme(household_waste_method, "highest_country")` (`r format_percent(extract_country_disposal_extreme(household_waste_method, "highest_pct"))`)**.
::: {.callout-note collapse="true" title="Show country-difference tests"}
The tests compare how often each method is selected in the 12 countries. They do not show which pairs of countries differ.
```{r}
#| label: tbl-country-disposal-tests
#| cache: false
#| tbl-cap: "Global country differences in selected disposal methods"
country_disposal_tests |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_number(
columns = c(F, df_num, df_den),
decimals = 1
) |>
cols_label(
F = "Overall test",
df_num = "df₁",
df_den = "df₂",
p_value = "p-value"
) |>
gt_bellegarde()
```
:::
```{r}
#| label: fig-country-disposal-methods
#| fig-cap: !expr figure_caption("Disposal methods selected by previous fast tech buyers, by country", paste0(format_percent(extract_country_disposal_extreme(recycling_centre_method, "highest_pct")), " of eligible respondents in ", extract_country_disposal_extreme(recycling_centre_method, "highest_country"), " reported taking devices to a recycling centre."))
#| fig-height: 8
country_disposal_method_summary |>
mutate(
Country = factor(
Country,
levels = sort(unique(Country))
),
Method = factor(
Method,
levels = rev(country_disposal_method_levels)
),
Method_plot = factor(
str_wrap(
as.character(Method),
width = 42
),
levels = str_wrap(
rev(country_disposal_method_levels),
width = 42
)
)
) |>
ggplot(aes(
x = Country,
y = Method_plot,
fill = method_type,
alpha = pct
)) +
geom_tile(
colour = bg_surface,
linewidth = bg_lw_hairline
) +
geom_text(
aes(label = percent(pct, accuracy = 0.1)),
colour = bg_ink,
alpha = 1,
size = (bg_size_label - 1) / .pt,
show.legend = FALSE
) +
scale_fill_manual(
name = NULL,
values = c(
"Recycling or reuse" = bg_accent,
"Waste disposal" = bg_pink,
"Kept, other or unknown" = bg_ink_32
),
guide = "none"
) +
scale_alpha_continuous(
range = c(0.2, 1),
labels = percent_format(),
name = "% selecting"
) +
guides(
alpha = guide_legend(
order = 1,
nrow = 1
)
) +
labs(
x = NULL,
y = NULL
) +
theme(
axis.text.x = element_text(
angle = 45,
hjust = 1
),
panel.grid = element_blank(),
legend.position = "top",
legend.box = "vertical"
)
```
# Behavioural models
```{r}
#| label: behavioural-models-data
model_country_levels <- sort(
unique(df$Country[!is.na(df$Country)])
)
figure_caption <- function(title, reading_key) {
paste0(
title,
"<br><span class='figure-key'><strong>How to read:</strong> ",
reading_key,
"</span>"
)
}
coefficient_term_label <- function(term) {
case_when(
term == "attitude_z" ~ "Attitude towards buying (1 SD)",
term == "injunctive_z" ~ "Injunctive norm (1 SD)",
term == "dynamic_z" ~ "Dynamic norm (1 SD)",
term == "buy_fewer_z" ~ "Support for buying fewer (1 SD)",
term == "regulation_z" ~ "Support for regulation (1 SD)",
term == "satisfaction_z" ~ "Device satisfaction (1 SD)",
term == "age_z" ~ "Age (1 SD)",
str_starts(term, "expected_lifespan") ~ paste0(
"Expected lifespan: ",
str_remove(term, "^expected_lifespan")
),
str_starts(term, "comparison_lifespan") ~ paste0(
"Lifespan compared with alternatives: ",
str_remove(term, "^comparison_lifespan")
),
str_starts(term, "gender") ~ paste0(
"Gender: ",
str_remove(term, "^gender")
),
TRUE ~ str_replace_all(term, "_", " ") |>
str_to_sentence()
)
}
model_coefficient_data <- function(
model,
robust_vcov = NULL,
pathway = NULL
) {
estimates <- coef(model)
if (is.matrix(estimates)) {
survey_estimates <- attr(
robust_vcov,
"survey_coef"
)
if (!is.null(survey_estimates)) {
estimates <- matrix(
survey_estimates,
nrow = nrow(estimates),
ncol = ncol(estimates),
byrow = TRUE,
dimnames = dimnames(estimates)
)
}
if (is.null(robust_vcov)) {
standard_errors <- summary(model)$standard.errors
} else {
standard_errors <- matrix(
sqrt(diag(robust_vcov)),
nrow = nrow(estimates),
ncol = ncol(estimates),
byrow = TRUE,
dimnames = dimnames(estimates)
)
}
result <- as.data.frame(as.table(estimates)) |>
as_tibble() |>
rename(
Outcome = Var1,
term = Var2,
beta = Freq
) |>
mutate(
standard_error = as.vector(standard_errors)
)
} else {
covariance <- robust_vcov %||% vcov(model)
result <- tibble(
term = names(estimates),
beta = as.numeric(estimates),
standard_error = sqrt(diag(covariance))
)
}
result |>
filter(
term != "(Intercept)",
!str_starts(term, "country")
) |>
mutate(
Predictor = map_chr(term, coefficient_term_label),
z = beta / standard_error,
p_value = 2 * pnorm(abs(z), lower.tail = FALSE),
conf_low = beta - qnorm(0.975) * standard_error,
conf_high = beta + qnorm(0.975) * standard_error,
Pathway = pathway,
.before = 1
) |>
select(
any_of(c("Pathway", "Outcome")),
Predictor,
beta,
standard_error,
conf_low,
conf_high,
p_value
)
}
coefficient_gt <- function(data) {
data <- data |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
)
)
group_column <- intersect(
c("Pathway", "Outcome"),
names(data)
) |>
first(default = NA_character_)
table <- if (is.na(group_column)) {
gt(data)
} else {
gt(data, groupname_col = group_column)
}
table |>
fmt_number(
columns = c(beta, standard_error, conf_low, conf_high),
decimals = 3
) |>
cols_label(
beta = "β",
standard_error = "SE",
conf_low = "95% CI low",
conf_high = "95% CI high",
p_value = "p-value"
) |>
cols_width(
Predictor ~ pct(42),
everything() ~ px(92)
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()
}
survey_binary_model_fit <- function(
model,
model_name,
model_terms
) {
test <- survey::regTermTest(
model,
reformulate(model_terms),
method = "Wald"
)
tibble(
Model = model_name,
N = nrow(model$survey.design$variables),
Wald = survey_test_statistic(test),
df = as.numeric(test$df),
p_value = as.numeric(test$p),
Pseudo_R2 = survey::psrsq(
model,
method = "Nagelkerke"
)
)
}
survey_multinomial_model_fit <- function(
model,
design_vcov,
model_name,
model_terms
) {
survey_multinomial_joint_test(
model,
design_vcov,
model_terms
) |>
mutate(
Model = model_name,
N = nrow(fitted(model)),
.before = 1
)
}
model_fit_gt <- function(data) {
table <- data |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_integer(columns = N) |>
fmt_number(
columns = any_of(c("Wald", "Pseudo_R2")),
decimals = 3
) |>
fmt_integer(columns = df) |>
cols_label(
Wald = "Overall test",
df = "df",
p_value = "p-value"
)
if ("Pseudo_R2" %in% names(data)) {
table <- table |>
cols_label(Pseudo_R2 = "Model fit")
}
table |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Model)
) |>
gt_bellegarde()
}
```
> **Note:** The following models take country, age and gender into account. They show links between answers, not causes.
## Ever having bought fast tech
```{r}
#| label: ever-bought-models-data
#| cache: true
ever_bought_base <- df |>
transmute(
analysis_weight,
ever_bought = factor(
if_else(
Buyer == "Buyer",
"Buyer",
"Non-buyer"
),
levels = c(
"Non-buyer",
"Buyer"
)
),
attitude = Attitude_buying_recoded,
injunctive = .data[["Injunctive norm_recoded"]],
dynamic = .data[["Dynamic norm_recoded"]],
buy_fewer = .data[["Consumers should buy fewer_recoded"]],
regulation = .data[[
"Governments should more strictly regulate_recoded"
]],
comparison_lifespan = factor(
Comparison_Lifespan,
levels = c(
"Similar lifespan",
"Shorter lifespan",
"Greater lifespan"
)
),
gender = factor(
if_else(
Gender %in% sociodemographic_gender_levels,
Gender,
NA_character_
),
levels = sociodemographic_gender_levels
),
country = factor(
Country,
levels = model_country_levels
),
age = Age
)
ever_model_data <- ever_bought_base |>
select(
ever_bought,
analysis_weight,
attitude,
injunctive,
dynamic,
buy_fewer,
regulation,
comparison_lifespan,
gender,
country,
age
) |>
drop_na() |>
mutate(
attitude_z = weighted_standardize(
attitude,
analysis_weight
),
injunctive_z = weighted_standardize(
injunctive,
analysis_weight
),
dynamic_z = weighted_standardize(
dynamic,
analysis_weight
),
buy_fewer_z = weighted_standardize(
buy_fewer,
analysis_weight
),
regulation_z = weighted_standardize(
regulation,
analysis_weight
),
age_z = weighted_standardize(
age,
analysis_weight
)
)
ever_model <- survey::svyglm(
ever_bought ~
attitude_z +
injunctive_z +
dynamic_z +
buy_fewer_z +
regulation_z +
comparison_lifespan +
gender +
country +
age_z,
design = make_analysis_design(ever_model_data),
family = quasibinomial()
)
survey_wald_term_tests <- function(
model,
terms
) {
map_dfr(
terms,
function(term) {
test <- survey::regTermTest(
model,
reformulate(term),
method = "Wald"
)
tibble(
term,
Wald = survey_test_statistic(test),
df = as.numeric(test$df),
p_value = as.numeric(test$p)
)
}
) |>
mutate(
q_value = p.adjust(
p_value,
method = "BH"
)
)
}
planned_lifespan_contrasts <- function(
model,
model_data,
type = c("response", "probs"),
vcov = NULL
) {
type <- match.arg(type)
lifespan_levels <- c(
"Similar lifespan",
"Shorter lifespan",
"Greater lifespan"
)
lifespan_counts <- tapply(
model_data$analysis_weight,
factor(
model_data$comparison_lifespan,
levels = lifespan_levels
),
sum
)
prediction_grid <- map_dfr(
lifespan_levels,
function(lifespan_level) {
model_data |>
mutate(
comparison_lifespan = factor(
lifespan_level,
levels = lifespan_levels
)
)
}
)
by_variables <- if (type == "probs") {
c("comparison_lifespan", "group")
} else {
"comparison_lifespan"
}
prediction_arguments <- list(
model = model,
newdata = prediction_grid,
by = by_variables,
type = type,
wts = "analysis_weight"
)
if (!is.null(vcov)) {
prediction_arguments$vcov <- vcov
}
predictions <- do.call(
marginaleffects::avg_predictions,
prediction_arguments
)
prediction_data <- predictions |>
as.data.frame() |>
as_tibble() |>
mutate(
prediction_row = row_number()
)
prediction_data$Outcome <- if (
"group" %in% names(prediction_data)
) {
as.character(prediction_data$group)
} else {
"Buyer"
}
prediction_vcov <- stats::vcov(predictions)
outcome_values <- unique(prediction_data$Outcome)
contrast_definitions <- list(
"Shorter vs non-shorter" = c(
"Similar lifespan" =
-lifespan_counts[["Similar lifespan"]] /
sum(lifespan_counts[c(
"Similar lifespan",
"Greater lifespan"
)]),
"Shorter lifespan" = 1,
"Greater lifespan" =
-lifespan_counts[["Greater lifespan"]] /
sum(lifespan_counts[c(
"Similar lifespan",
"Greater lifespan"
)])
),
"Greater vs non-greater" = c(
"Similar lifespan" =
-lifespan_counts[["Similar lifespan"]] /
sum(lifespan_counts[c(
"Similar lifespan",
"Shorter lifespan"
)]),
"Shorter lifespan" =
-lifespan_counts[["Shorter lifespan"]] /
sum(lifespan_counts[c(
"Similar lifespan",
"Shorter lifespan"
)]),
"Greater lifespan" = 1
)
)
contrast_rows <- crossing(
Outcome = outcome_values,
Contrast = names(contrast_definitions)
)
contrast_matrix <- matrix(
0,
nrow = nrow(contrast_rows),
ncol = nrow(prediction_data)
)
for (row_index in seq_len(nrow(contrast_rows))) {
outcome_value <- contrast_rows$Outcome[[row_index]]
contrast_name <- contrast_rows$Contrast[[row_index]]
weights <- contrast_definitions[[contrast_name]]
relevant_rows <- prediction_data |>
filter(Outcome == outcome_value)
contrast_matrix[
row_index,
relevant_rows$prediction_row
] <- weights[
as.character(relevant_rows$comparison_lifespan)
]
}
estimates <- as.numeric(
contrast_matrix %*% prediction_data$estimate
)
standard_errors <- sqrt(diag(
contrast_matrix %*%
prediction_vcov %*%
t(contrast_matrix)
))
contrast_rows |>
mutate(
term = "comparison_lifespan",
estimate = estimates,
std_error = standard_errors,
conf_low = estimate - qnorm(0.975) * std_error,
conf_high = estimate + qnorm(0.975) * std_error,
p_value = 2 * pnorm(
-abs(estimate / std_error)
)
)
}
ever_model_terms <- c(
"attitude_z",
"injunctive_z",
"dynamic_z",
"buy_fewer_z",
"regulation_z",
"comparison_lifespan",
"gender",
"country",
"age_z"
)
ever_term_tests <- survey_wald_term_tests(
ever_model,
ever_model_terms
)
ever_term_labels <- c(
attitude_z = "Attitude towards buying",
injunctive_z = "Injunctive norm",
dynamic_z = "Dynamic norm",
buy_fewer_z = "Support for buying fewer items",
regulation_z = "Support for stricter regulation",
comparison_lifespan = "Lifespan compared with alternatives",
gender = "Gender",
country = "Country",
age_z = "Age"
)
prepare_binary_marginal_effects <- function(
model,
term_tests,
model_data
) {
variables <- term_tests |>
filter(
q_value < 0.05,
!term %in% c(
"gender",
"country",
"age_z"
)
) |>
pull(term)
if (length(variables) == 0) {
return(tibble())
}
marginaleffects::avg_slopes(
model,
variables = variables,
type = "response",
wts = model_data$analysis_weight,
vcov = vcov(model)
) |>
as.data.frame() |>
as_tibble() |>
transmute(
term,
Predictor = if_else(
contrast == "dY/dX",
unname(ever_term_labels[term]),
paste0(
unname(ever_term_labels[term]),
": ",
str_replace(
contrast,
" - ",
" vs "
)
)
),
estimate,
conf_low = conf.low,
conf_high = conf.high
)
}
ever_marginal_effects <- prepare_binary_marginal_effects(
ever_model,
ever_term_tests,
ever_model_data
)
ever_lifespan_contrasts <- planned_lifespan_contrasts(
ever_model,
ever_model_data,
type = "response",
vcov = vcov(ever_model)
) |>
transmute(
term,
Predictor = paste0(
"Lifespan compared with alternatives: ",
Contrast
),
estimate,
conf_low,
conf_high
)
if (ever_term_tests$q_value[
ever_term_tests$term == "comparison_lifespan"
] < 0.05) {
ever_marginal_effects <- bind_rows(
ever_marginal_effects |>
filter(term != "comparison_lifespan"),
ever_lifespan_contrasts
)
} else {
ever_marginal_effects <- ever_marginal_effects |>
filter(term != "comparison_lifespan")
}
ever_attitude_effect <- ever_marginal_effects |>
filter(Predictor == "Attitude towards buying") |>
pull(estimate)
ever_model_overview <- ever_term_tests |>
mutate(
Predictor = unname(
ever_term_labels[term]
)
) |>
select(
Predictor,
Wald,
df,
p_value,
q_value
)
ever_global_fit <- survey_binary_model_fit(
ever_model,
"Ever having bought fast tech",
ever_model_terms
)
maximum_coefficient_vif <- function(model) {
matrix <- model.matrix(model)[
,
-1,
drop = FALSE
]
max(
map_dbl(
seq_len(ncol(matrix)),
function(index) {
focal <- matrix[, index]
others <- matrix[
,
-index,
drop = FALSE
]
auxiliary <- lm.fit(
cbind(intercept = 1, others),
focal
)
r_squared <- 1 -
sum(auxiliary$residuals^2) /
sum((focal - mean(focal))^2)
1 / (1 - r_squared)
}
)
)
}
ever_diagnostics <- tibble(
Check = c(
"Complete-case sample",
"Convergence",
"Maximum coefficient-level VIF",
"Smallest response group",
"Finite estimates"
),
Result = c(
format_count(nrow(ever_model_data)),
if_else(ever_model$converged, "Passed", "Failed"),
number(
maximum_coefficient_vif(ever_model),
accuracy = 0.01
),
format_count(
min(table(ever_model_data$ever_bought))
),
if_else(
all(
is.finite(coef(ever_model)),
is.finite(vcov(ever_model))
),
"Passed",
"Failed"
)
)
)
```
This model estimates how attitudes, norms, lifespan perceptions and socio-demographic characteristics distinguish buyers from non-buyers. The chart expresses the results as changes in probability, so a value of +5% means an average increase of five percentage points in the probability of having bought fast tech.
::: {.callout-note collapse="true" title="Show tables and assumptions"}
```{r}
#| label: tbl-ever-bought-global-fit
#| tbl-cap: "Global fit of the ever-bought model"
ever_global_fit |>
model_fit_gt()
```
```{r}
#| label: tbl-ever-bought-model-overview
#| tbl-cap: "Overall contribution of predictors to the ever-bought model"
ever_model_overview |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
),
q_value = format.pval(
q_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_number(
columns = Wald,
decimals = 1
) |>
cols_label(
Wald = "Overall test",
df = "df",
p_value = "p-value",
q_value = "Corrected p"
) |>
cols_width(
Predictor ~ pct(48),
Wald ~ px(105)
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()
```
Coefficients are on the logistic scale. Country coefficients are included in the model but omitted from the table to keep the substantive predictors readable.
```{r}
#| label: tbl-ever-bought-coefficients
#| tbl-cap: "Coefficient estimates for the ever-bought model"
model_coefficient_data(ever_model) |>
coefficient_gt()
```
The model uses one record per respondent. For scored questions, it follows the overall rise or fall in answers.
```{r}
#| label: tbl-ever-bought-diagnostics
#| tbl-cap: "Assumption and diagnostic checks for the ever-bought model"
ever_diagnostics |>
gt() |>
gt_bellegarde()
```
:::
```{r}
#| label: fig-ever-bought-marginal-effects
#| cache: false
#| fig-cap: !expr figure_caption("Factors linked with having bought fast tech", paste0("A more favourable buying attitude is linked with a ", percent(ever_attitude_effect, accuracy = 0.1, style_positive = "plus"), " difference in the probability of having bought fast tech."))
#| fig-height: 6
ever_marginal_effects |>
mutate(
Predictor = fct_reorder(
str_wrap(Predictor, 38),
estimate
)
) |>
ggplot(aes(
x = estimate,
y = Predictor
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_errorbar(
aes(
xmin = conf_low,
xmax = conf_high
),
orientation = "y",
width = 0,
linewidth = bg_lw_line,
colour = bg_pink
) +
geom_point(
size = bg_size_label / .pt,
colour = bg_pink
) +
scale_x_continuous(
labels = label_percent(
accuracy = 0.1,
style_positive = "plus"
)
) +
labs(
x = "Average change in probability of being a buyer",
y = NULL
)
```
> **Note:** Product satisfaction and expected lifespan are not included, since they were asked only of previous buyers. For scored questions, each bar shows a typical increase. Only the clearest factors are shown. The model does not establish cause and effect.
::: {.parametre}
<div class="parametre-title">Top 3 factors</div>
1. **Attitude towards buying.** The more favourable respondents’ attitudes towards buying fast tech devices, the more likely they are to have bought them.
2. **Age.** Younger people are more likely to have bought fast tech.
3. **Perceived approval.** People who feel that those close to them approve of fast tech are more likely to have bought it.
:::
### Behavioural path model
```{r}
#| label: behavioural-path-model-data
#| cache: true
if (!requireNamespace("lavaan", quietly = TRUE)) {
stop("Install the 'lavaan' package before rendering this section.")
}
behavioural_path_base <- ever_model_data |>
mutate(
ever_bought = ordered(
ever_bought,
levels = c("Non-buyer", "Buyer")
),
shorter_lifespan = as.numeric(
comparison_lifespan == "Shorter lifespan"
),
greater_lifespan = as.numeric(
comparison_lifespan == "Greater lifespan"
)
)
behavioural_path_controls <- model.matrix(
~ gender + country,
data = behavioural_path_base
)[, -1, drop = FALSE]
colnames(behavioural_path_controls) <- make.names(
colnames(behavioural_path_controls),
unique = TRUE
)
behavioural_path_data <- bind_cols(
behavioural_path_base,
as_tibble(behavioural_path_controls)
)
behavioural_path_control_terms <- colnames(
behavioural_path_controls
)
behavioural_path_model_syntax <- paste0(
"attitude_z ~ injunctive_z + dynamic_z + buy_fewer_z + ",
"regulation_z + shorter_lifespan + greater_lifespan + age_z + ",
paste(behavioural_path_control_terms, collapse = " + "),
"\n",
"ever_bought ~ attitude_z + injunctive_z + greater_lifespan + age_z + ",
paste(behavioural_path_control_terms, collapse = " + ")
)
behavioural_path_model <- lavaan::sem(
model = behavioural_path_model_syntax,
data = behavioural_path_data,
ordered = "ever_bought",
estimator = "WLSMV",
sampling.weights = "analysis_weight",
std.lv = TRUE
)
behavioural_path_fit <- lavaan::fitMeasures(
behavioural_path_model,
c("cfi", "tli", "rmsea", "srmr")
)
behavioural_path_labels <- c(
injunctive_z = "Perceived approval",
dynamic_z = "Dynamic norm",
buy_fewer_z = "Support for buying fewer",
regulation_z = "Support for regulation",
shorter_lifespan = "Shorter lifespan",
greater_lifespan = "Greater lifespan",
age_z = "Age (+1 SD)",
attitude_z = "Buying attitude",
ever_bought = "Ever bought fast tech"
)
behavioural_path_estimates <-
lavaan::standardizedSolution(behavioural_path_model) |>
as_tibble() |>
filter(
op == "~",
lhs %in% c("attitude_z", "ever_bought"),
rhs %in% names(behavioural_path_labels)
) |>
transmute(
Outcome = unname(behavioural_path_labels[lhs]),
Predictor = unname(behavioural_path_labels[rhs]),
from = rhs,
to = lhs,
beta = est.std,
standard_error = se,
p_value = pvalue,
Direction = if_else(beta >= 0, "Positive", "Negative")
)
behavioural_path_age_effect <- marginaleffects::avg_slopes(
ever_model,
variables = "age_z",
type = "response",
wts = ever_model_data$analysis_weight,
vcov = vcov(ever_model)
) |>
as.data.frame() |>
as_tibble() |>
transmute(
from = "age_z",
probability_difference = estimate,
conf_low = conf.low,
conf_high = conf.high
)
behavioural_path_purchase_effects <- bind_rows(
ever_marginal_effects |>
filter(Predictor == "Attitude towards buying") |>
transmute(
from = "attitude_z",
probability_difference = estimate,
conf_low,
conf_high
),
ever_marginal_effects |>
filter(Predictor == "Injunctive norm") |>
transmute(
from = "injunctive_z",
probability_difference = estimate,
conf_low,
conf_high
),
ever_marginal_effects |>
filter(str_detect(Predictor, "Greater vs")) |>
transmute(
from = "greater_lifespan",
probability_difference = estimate,
conf_low,
conf_high
),
behavioural_path_age_effect
)
behavioural_path_plot_data <- bind_rows(
behavioural_path_estimates |>
filter(to == "attitude_z") |>
transmute(
Stage = "Buying attitude\nEffect in standard deviations",
Predictor,
effect = beta,
conf_low = beta - 1.96 * standard_error,
conf_high = beta + 1.96 * standard_error,
Direction = if_else(effect >= 0, "Positive", "Negative"),
display_label = paste0(
number(
effect,
accuracy = 0.01,
style_positive = "plus"
),
" SD"
)
),
behavioural_path_purchase_effects |>
transmute(
Stage = "Ever bought fast tech\nEffect in percentage points",
Predictor = unname(behavioural_path_labels[from]),
effect = probability_difference * 100,
conf_low = conf_low * 100,
conf_high = conf_high * 100,
Direction = if_else(effect >= 0, "Positive", "Negative"),
display_label = paste0(
number(
effect,
accuracy = 0.1,
style_positive = "plus"
),
" pp"
)
)
)
path_attitude_purchase_effect <-
behavioural_path_purchase_effects |>
filter(from == "attitude_z") |>
pull(probability_difference)
```
**Perceived approval has the strongest link with buying attitude.** People who feel stronger approval also tend to view fast tech more favourably. A typical increase in buying attitude is linked with a `r percent(path_attitude_purchase_effect, accuracy = 0.1, style_positive = "plus")` difference in the probability of having bought fast tech.
```{r}
#| label: fig-behavioural-path-model
#| fig-cap: !expr figure_caption("Exploratory behavioural path model", paste0("A typical increase in buying attitude corresponds to a ", percent(path_attitude_purchase_effect, accuracy = 0.1, style_positive = "plus"), " difference in the probability of having bought fast tech."))
#| fig-height: 8
behavioural_path_plot_data |>
mutate(
Predictor = fct_reorder(
str_wrap(Predictor, 28),
effect
)
) |>
ggplot(aes(
x = effect,
y = Predictor,
colour = Direction
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_errorbarh(
aes(xmin = conf_low, xmax = conf_high),
height = 0,
linewidth = bg_lw_line
) +
geom_point(size = bg_point_size) +
geom_text(
aes(
label = display_label,
hjust = if_else(effect >= 0, -0.18, 1.18)
),
colour = bg_ink,
size = bg_size_label / .pt,
show.legend = FALSE
) +
scale_colour_manual(
values = c(
Positive = bg_accent,
Negative = bg_pink
),
guide = "none"
) +
facet_wrap(
~Stage,
ncol = 1,
scales = "free"
) +
scale_x_continuous(
labels = label_number(
accuracy = 0.1,
style_positive = "plus"
),
expand = expansion(mult = c(0.18, 0.22))
) +
labs(
x = "Strength of link",
y = NULL
) +
theme(
panel.spacing.y = grid::unit(bg_margin_pt, "pt"),
strip.text = element_text(face = "bold")
)
```
::: {.callout-note collapse="true" title="Show tables and assumptions"}
Arrows ending at buying attitude show the relative strength of each link. Arrows ending at purchase show average differences in probability.
```{r}
#| label: tbl-behavioural-path-model
#| cache: false
#| tbl-cap: "Standardised paths in the exploratory behavioural model"
behavioural_path_estimates |>
select(
Outcome,
Predictor,
beta,
standard_error,
p_value
) |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
)
) |>
gt(groupname_col = "Outcome") |>
fmt_number(
columns = c(beta, standard_error),
decimals = 3
) |>
cols_label(
beta = "Standardised β",
standard_error = "SE",
p_value = "p-value"
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()
```
The path model uses the same complete records as the buying model. Country and gender are included but omitted from the diagram. This is an exploratory view: the data cannot show which factor causes another.
:::
## Perception & willingness
### Willingness to pay more for longer-lasting alternatives
```{r}
#| label: pay-more-models-data
required_packages <- c(
"nnet",
"marginaleffects"
)
missing_packages <- required_packages[
!vapply(
required_packages,
requireNamespace,
logical(1),
quietly = TRUE
)
]
if (length(missing_packages) > 0) {
stop(
"Install before rendering: ",
paste(missing_packages, collapse = ", ")
)
}
behavioural_predictor_data <- df |>
transmute(
respondent_id = row_number(),
analysis_weight,
satisfaction = Satisfaction_recoded,
expected_lifespan = factor(
Global_Lifespan,
levels = c(
"Expected lifespan",
"Shorter lifespan",
"Longer lifespan"
)
),
comparison_lifespan = factor(
Comparison_Lifespan,
levels = c(
"Similar lifespan",
"Shorter lifespan",
"Greater lifespan"
)
),
attitude = Attitude_buying_recoded,
injunctive = .data[["Injunctive norm_recoded"]],
dynamic = .data[["Dynamic norm_recoded"]],
buy_fewer = .data[["Consumers should buy fewer_recoded"]],
regulation = .data[[
"Governments should more strictly regulate_recoded"
]],
gender = factor(
if_else(
Gender %in% sociodemographic_gender_levels,
Gender,
NA_character_
),
levels = sociodemographic_gender_levels
),
country = factor(
Country,
levels = model_country_levels
),
age = Age
)
continuous_behavioural_predictors <- c(
"satisfaction",
"attitude",
"injunctive",
"dynamic",
"buy_fewer",
"regulation"
)
behavioural_predictor_names <- c(
"satisfaction",
"expected_lifespan",
"comparison_lifespan",
"attitude",
"injunctive",
"dynamic",
"buy_fewer",
"regulation"
)
behavioural_predictor_labels <- c(
satisfaction_z = "Device satisfaction",
expected_lifespan = "Expected lifespan",
expected_lifespan_longer =
"Expected lifespan",
comparison_lifespan =
"Lifespan compared with alternatives",
attitude_z = "Attitude towards buying",
injunctive_z = "Injunctive norm",
dynamic_z = "Dynamic norm",
buy_fewer_z = "Support for buying fewer items",
regulation_z = "Support for stricter regulation",
gender = "Gender",
country = "Country",
age_z = "Age"
)
build_behavioural_model_terms <- function(variables) {
c(
if_else(
variables %in% continuous_behavioural_predictors,
paste0(variables, "_z"),
variables
),
"gender",
"country",
"age_z"
)
}
prepare_behavioural_model_data <- function(
outcome_data,
outcome_name,
variables = behavioural_predictor_names
) {
outcome_data |>
left_join(
behavioural_predictor_data,
by = "respondent_id"
) |>
select(
all_of(outcome_name),
all_of(variables),
gender,
country,
age,
analysis_weight
) |>
drop_na() |>
mutate(
across(
all_of(intersect(
variables,
continuous_behavioural_predictors
)),
~ weighted_standardize(
.x,
analysis_weight
),
.names = "{.col}_z"
),
age_z = weighted_standardize(
age,
analysis_weight
)
)
}
pay_more_outcome_data <- df |>
transmute(
respondent_id = row_number(),
pay_more = factor(
as.character(.data[["Pay more"]]),
levels = willingness_to_pay_levels,
ordered = FALSE
)
)
pay_more_model_data <- prepare_behavioural_model_data(
pay_more_outcome_data,
"pay_more"
)
pay_more_terms <- build_behavioural_model_terms(
behavioural_predictor_names
)
pay_more_formula <- reformulate(
pay_more_terms,
response = "pay_more"
)
pay_more_fit <- fit_survey_multinomial(
pay_more_formula,
data = pay_more_model_data
)
pay_more_model <- pay_more_fit$model
pay_more_vcov <- pay_more_fit$vcov
pay_more_term_tests <- survey_multinomial_term_tests(
pay_more_model,
pay_more_vcov,
pay_more_terms
)
prepare_multinomial_marginal_effects <- function(
model,
term_tests,
model_data,
design_vcov
) {
variables <- term_tests |>
filter(
q_value < 0.05,
!term %in% c(
"gender",
"country",
"age_z"
)
) |>
pull(term)
marginaleffects::avg_slopes(
model,
variables = variables,
type = "probs",
wts = model_data$analysis_weight,
vcov = design_vcov
) |>
as.data.frame() |>
as_tibble() |>
transmute(
term,
Outcome = factor(
group,
levels = willingness_to_pay_levels,
labels = c(
"No",
"Yes: some",
"Yes: most",
"Yes: all"
)
),
Predictor = if_else(
contrast == "dY/dX",
unname(behavioural_predictor_labels[term]),
paste0(
unname(behavioural_predictor_labels[term]),
": ",
str_replace(
contrast,
" - ",
" vs "
)
)
),
estimate,
conf_low = conf.low,
conf_high = conf.high
)
}
pay_more_marginal_effects <- prepare_multinomial_marginal_effects(
pay_more_model,
pay_more_term_tests,
pay_more_model_data,
pay_more_vcov
)
pay_more_lifespan_contrasts <- planned_lifespan_contrasts(
pay_more_model,
pay_more_model_data,
type = "probs",
vcov = pay_more_vcov
) |>
transmute(
term,
Outcome = factor(
Outcome,
levels = willingness_to_pay_levels,
labels = c(
"No",
"Yes: some",
"Yes: most",
"Yes: all"
)
),
Predictor = paste0(
"Lifespan compared with alternatives: ",
Contrast
),
estimate,
conf_low,
conf_high
)
if (pay_more_term_tests$q_value[
pay_more_term_tests$term == "comparison_lifespan"
] < 0.05) {
pay_more_marginal_effects <- bind_rows(
pay_more_marginal_effects |>
filter(term != "comparison_lifespan"),
pay_more_lifespan_contrasts
)
} else {
pay_more_marginal_effects <- pay_more_marginal_effects |>
filter(term != "comparison_lifespan")
}
diagnostics_for_model <- function(
model,
data,
design_vcov,
outcome,
inference_converged
) {
tibble(
Check = c(
"Convergence",
"Predictor overlap",
"Smallest response group",
"Stable estimates",
"Uncertainty"
),
Result = c(
if_else(
model$convergence == 0 && inference_converged,
"Yes",
"No"
),
paste0(
"Low (",
scales::number(
maximum_coefficient_vif(model),
accuracy = 0.01
),
")"
),
paste0(
format_count(min(table(data[[outcome]]))),
" people"
),
if_else(
all(
is.finite(coef(model)),
is.finite(design_vcov)
),
"Yes",
"No"
),
"Included"
),
Interpretation = c(
"Optimisation completed normally",
"The factors remain distinct",
"Every answer has enough people",
"No numerical warning",
"Ranges show uncertainty around each result"
)
)
}
pay_more_diagnostics <- diagnostics_for_model(
pay_more_model,
pay_more_model_data,
pay_more_vcov,
"pay_more",
pay_more_fit$inference_converged
)
pay_more_model_overview <- pay_more_term_tests |>
mutate(
Predictor = unname(behavioural_predictor_labels[term])
) |>
select(
Predictor,
Wald,
df,
p_value,
q_value
)
pay_more_global_fit <- survey_multinomial_model_fit(
pay_more_model,
pay_more_vcov,
"Willingness to pay more",
pay_more_terms
)
```
This model shows which factors shift respondents between the four willingness-to-pay responses. The chart reports changes in probability for each answer, making the direction and size of those shifts directly readable.
::: {.callout-note collapse="true" title="Show tables and assumptions"}
```{r}
#| label: tbl-pay-more-global-fit
#| tbl-cap: "Global fit of the willingness-to-pay model"
pay_more_global_fit |>
model_fit_gt()
```
```{r}
#| label: tbl-pay-more-overview
#| tbl-cap: "Overall contribution of predictors to the willingness-to-pay model"
pay_more_model_overview |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
),
q_value = format.pval(
q_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_number(
columns = Wald,
decimals = 1
) |>
cols_label(
Wald = "Overall test",
df = "df",
p_value = "p-value",
q_value = "Corrected p"
) |>
cols_width(
Predictor ~ pct(48),
Wald ~ px(105)
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()
```
Each outcome is compared with the model’s reference response. Country coefficients are omitted from display but remain in the model.
```{r}
#| label: tbl-pay-more-coefficients
#| tbl-cap: "Coefficient estimates for the willingness-to-pay model"
model_coefficient_data(
pay_more_model,
robust_vcov = pay_more_vcov
) |>
coefficient_gt()
```
The model treats the four answers separately. The checks show whether the calculation was stable and whether every answer has enough people.
```{r}
#| label: tbl-pay-more-diagnostics
#| tbl-cap: "Assumption and diagnostic checks for the willingness-to-pay model"
pay_more_diagnostics |>
gt() |>
cols_width(
Check ~ pct(25),
Result ~ pct(22),
Interpretation ~ pct(53)
) |>
gt_bellegarde()
```
:::
```{r}
#| label: fig-pay-more-marginal-effects
#| cache: false
#| fig-cap: !expr figure_caption("Factors linked with willingness to pay more", "Respondents reporting a longer-than-expected lifespan are less likely to say they would pay more for only some devices.")
#| fig-height: 10
pay_more_marginal_effects |>
mutate(
Predictor = fct_reorder(
str_wrap(Predictor, 42),
estimate
)
) |>
ggplot(aes(
x = estimate,
y = Predictor,
colour = Outcome
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_errorbar(
aes(
xmin = conf_low,
xmax = conf_high
),
orientation = "y",
position = position_dodge(width = 0.65),
width = 0,
linewidth = bg_lw_line
) +
geom_point(
position = position_dodge(width = 0.65),
size = bg_size_label / .pt
) +
scale_x_continuous(
labels = label_percent(accuracy = 0.1)
) +
scale_colour_manual(
values = c(
"No" = bg_ink_54,
"Yes: some" = bg_accent_20,
"Yes: most" = bg_accent_50,
"Yes: all" = bg_accent
),
name = NULL
) +
labs(
x = "Change in probability",
y = NULL
) +
theme(
legend.position = "top"
)
```
> **Note:** For scored questions, each bar shows a typical increase. The lifespan bars compare shorter with other answers, and greater with other answers. Positive and negative values show how probability moves between the four choices. The model does not establish causality.
::: {.parametre}
<div class="parametre-title">Top 3 factors</div>
1. **Expected lifespan.** Respondents expecting a longer lifespan are less likely to say they would pay more for only some devices.
2. **Lifespan compared with alternatives.** Perceiving a greater lifespan is associated with greater willingness to pay more for most devices than not perceiving that advantage.
3. **Support for stricter regulation.** The stronger the support for regulation, the less likely respondents are to reject paying more.
:::
## Willingness to replace a broken fast tech device
```{r}
#| label: outcome-model-helpers
#| cache: true
fit_multinomial_specification <- function(
outcome_data,
outcome_name,
outcome_levels
) {
variables <- behavioural_predictor_names
model_data <- prepare_behavioural_model_data(
outcome_data,
outcome_name,
variables
)
terms <- build_behavioural_model_terms(variables) |>
setdiff("gender")
formula <- reformulate(
terms,
response = outcome_name
)
survey_fit <- fit_survey_multinomial(
formula,
data = model_data
)
model <- survey_fit$model
design_vcov <- survey_fit$vcov
model_tests <- survey_multinomial_term_tests(
model,
design_vcov,
terms
)
marginal_effect_variables <- model_tests |>
filter(
q_value < 0.05,
!term %in% c(
"gender",
"country",
"age_z"
)
) |>
pull(term)
marginal_effects <- marginaleffects::avg_slopes(
model,
variables = marginal_effect_variables,
type = "probs",
wts = model_data$analysis_weight,
vcov = design_vcov
) |>
as.data.frame() |>
as_tibble() |>
transmute(
term,
Outcome = factor(
group,
levels = outcome_levels
),
Predictor = if_else(
contrast == "dY/dX",
unname(
behavioural_predictor_labels[term]
),
paste0(
unname(
behavioural_predictor_labels[term]
),
": ",
str_replace(
contrast,
" - ",
" vs "
)
)
),
estimate,
conf_low = conf.low,
conf_high = conf.high
)
lifespan_contrasts <- planned_lifespan_contrasts(
model,
model_data,
type = "probs",
vcov = design_vcov
) |>
transmute(
term,
Outcome = factor(
Outcome,
levels = outcome_levels
),
Predictor = paste0(
"Lifespan compared with alternatives: ",
Contrast
),
estimate,
conf_low,
conf_high
)
if (model_tests$q_value[
model_tests$term == "comparison_lifespan"
] < 0.05) {
marginal_effects <- bind_rows(
marginal_effects |>
filter(term != "comparison_lifespan"),
lifespan_contrasts
)
} else {
marginal_effects <- marginal_effects |>
filter(term != "comparison_lifespan")
}
diagnostics <- tibble(
Check = c(
"Convergence",
"Predictor overlap",
"Smallest response group",
"Stable estimates",
"Uncertainty"
),
Result = c(
if_else(
model$convergence == 0 &&
survey_fit$inference_converged,
"Yes",
"No"
),
paste0(
"Low (",
scales::number(
maximum_coefficient_vif(model),
accuracy = 0.01
),
")"
),
paste0(
format_count(
min(table(model_data[[outcome_name]]))
),
" people"
),
if_else(
all(
is.finite(coef(model)),
is.finite(design_vcov)
),
"Yes",
"No"
),
"Included"
),
Interpretation = c(
"Optimisation completed normally",
"The factors remain distinct",
"Every answer has enough people",
"No numerical warning",
"Ranges show uncertainty around each result"
)
)
list(
data = model_data,
model = model,
vcov = design_vcov,
tests = model_tests,
marginal_effects = marginal_effects,
diagnostics = diagnostics
)
}
fit_outcome_model <- function(
outcome_data,
outcome_name,
outcome_levels
) {
results <- fit_multinomial_specification(
outcome_data,
outcome_name,
outcome_levels
)
overview <- results$tests |>
mutate(
Predictor = unname(
behavioural_predictor_labels[term]
)
) |>
select(
Predictor,
Wald,
df,
p_value,
q_value
)
list(
data = results$data,
model = results$model,
vcov = results$vcov,
tests = results$tests,
overview = overview,
marginal_effects = results$marginal_effects,
diagnostics = results$diagnostics
)
}
outcome_model_overview_table <- function(results) {
results$overview |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
),
q_value = format.pval(
q_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_number(
columns = Wald,
decimals = 1
) |>
cols_label(
Wald = "Overall test",
df = "df",
p_value = "p-value",
q_value = "Corrected p"
) |>
cols_width(
Predictor ~ pct(48),
Wald ~ px(105)
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()
}
outcome_model_diagnostics_table <- function(results) {
results$diagnostics |>
gt() |>
cols_width(
Check ~ pct(25),
Result ~ pct(22),
Interpretation ~ pct(53)
) |>
gt_bellegarde()
}
```
```{r}
#| label: replacement-models-data
#| cache: true
replacement_outcome_data <- df |>
transmute(
respondent_id = row_number(),
replacement = factor(
Replace,
levels = replacement_choice_levels
)
) |>
filter(!is.na(replacement))
replacement_results <- fit_outcome_model(
replacement_outcome_data,
"replacement",
replacement_choice_levels
)
replacement_global_fit <- survey_multinomial_model_fit(
replacement_results$model,
replacement_results$vcov,
"Replacement choice",
replacement_results$tests$term
)
```
This model shows which factors shift respondents between the five possible replacement choices. The chart expresses these results as probability differences for each choice.
::: {.callout-note collapse="true" title="Show tables and assumptions"}
```{r}
#| label: tbl-replacement-global-fit
#| tbl-cap: "Global fit of the replacement-choice model"
replacement_global_fit |>
model_fit_gt()
```
```{r}
#| label: tbl-replacement-model-overview
#| tbl-cap: "Overall contribution of predictors to the replacement-choice model"
outcome_model_overview_table(
replacement_results
)
```
Each replacement response is compared with the reference choice. Country coefficients are omitted from display but remain in the model.
```{r}
#| label: tbl-replacement-coefficients
#| tbl-cap: "Coefficient estimates for the replacement-choice model"
model_coefficient_data(
replacement_results$model,
robust_vcov = replacement_results$vcov
) |>
coefficient_gt()
```
The model treats the five replacement choices separately. The checks show whether the calculation was stable and whether every answer has enough people.
```{r}
#| label: tbl-replacement-diagnostics
#| tbl-cap: "Assumption and diagnostic checks for the replacement-choice model"
outcome_model_diagnostics_table(
replacement_results
)
```
:::
```{r}
#| label: fig-replacement-marginal-effects
#| cache: false
#| fig-cap: !expr figure_caption("Factors linked with replacement choices", "People who see fast tech as short-lived are more likely to choose a more expensive replacement.")
#| fig-height: 10
replacement_results$marginal_effects |>
mutate(
Predictor = fct_reorder(
str_wrap(Predictor, 42),
estimate
)
) |>
ggplot(aes(
x = estimate,
y = Predictor,
colour = Outcome
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_errorbar(
aes(
xmin = conf_low,
xmax = conf_high
),
orientation = "y",
position = position_dodge(width = 0.65),
width = 0,
linewidth = bg_lw_line
) +
geom_point(
position = position_dodge(width = 0.65),
size = bg_size_label / .pt
) +
scale_x_continuous(
labels = label_percent(
accuracy = 0.1,
style_positive = "plus"
)
) +
scale_colour_manual(
values = c(
"Buy the exact same small and inexpensive model" =
bg_accent_20,
"Buy another small and inexpensive device" =
bg_accent_50,
"Buy a more expensive one" = bg_accent,
"Choose not to replace it" = bg_pink,
"Other" = bg_ink_32
),
labels = function(x) {
str_wrap(x, width = 24)
},
name = NULL
) +
guides(
colour = guide_legend(
nrow = 3,
byrow = TRUE
)
) +
labs(
x = "Change in probability",
y = NULL
) +
theme(
legend.position = "bottom"
)
```
> **Note:** For scored questions, each bar shows a typical increase. The lifespan bars compare shorter with other answers, and greater with other answers. Positive and negative values show how probability moves between the replacement choices. The model does not establish causality.
::: {.parametre}
<div class="parametre-title">Top 3 factors</div>
1. **Lifespan compared with alternatives.** Perceiving fast tech as shorter-lived is associated with a greater probability of choosing a more expensive replacement than reporting no durability deficit.
2. **Expected lifespan.** A shorter-than-expected lifespan makes choosing another inexpensive device less likely.
3. **Support for buying fewer items.** Stronger support for buying fewer devices makes choosing a more expensive replacement more likely.
:::
## Future buying intention
```{r}
#| label: future-intention-models-data
#| cache: false
future_intention_outcome_data <- df |>
transmute(
respondent_id = row_number(),
previous_buyer = Buyer == "Buyer",
future_intention = ordered(
.data[["12months_Intentionbuy"]],
levels = future_purchase_intention_levels
)
) |>
filter(
previous_buyer,
!is.na(future_intention)
) |>
mutate(
future_intention_score =
as.numeric(future_intention) - 1,
reduce_or_stop = as.integer(
future_intention_score <= 2
),
increase_purchases = as.integer(
future_intention_score >= 4
)
)
future_intention_score_data <-
prepare_behavioural_model_data(
future_intention_outcome_data |>
select(
respondent_id,
future_intention_score
),
"future_intention_score",
behavioural_predictor_names
)
future_intention_terms <- build_behavioural_model_terms(
behavioural_predictor_names
)
future_intention_score_formula <- reformulate(
future_intention_terms,
response = "future_intention_score"
)
future_intention_score_model <- survey::svyglm(
future_intention_score_formula,
design = make_analysis_design(
future_intention_score_data
),
family = gaussian()
)
future_intention_score_vcov <- vcov(
future_intention_score_model
)
future_intention_term_tests <-
survey_wald_term_tests(
future_intention_score_model,
future_intention_terms
)
future_intention_overview <-
future_intention_term_tests |>
mutate(
Predictor = unname(
behavioural_predictor_labels[term]
)
) |>
select(
Predictor,
Wald,
df,
p_value,
q_value
)
future_intention_significant_terms <-
future_intention_term_tests |>
filter(
q_value < 0.05,
!term %in% c(
"gender",
"country",
"age_z"
)
) |>
pull(term)
future_intention_score_effects <-
marginaleffects::avg_slopes(
future_intention_score_model,
variables = future_intention_significant_terms,
vcov = future_intention_score_vcov,
wts = future_intention_score_data$analysis_weight
) |>
as.data.frame() |>
as_tibble() |>
transmute(
term,
Predictor = if_else(
contrast == "dY/dX",
unname(behavioural_predictor_labels[term]),
paste0(
unname(behavioural_predictor_labels[term]),
": ",
str_replace(
contrast,
" - ",
" vs "
)
)
),
estimate,
conf_low = conf.low,
conf_high = conf.high
)
future_intention_lifespan_score_contrasts <-
planned_lifespan_contrasts(
future_intention_score_model,
future_intention_score_data,
type = "response",
vcov = future_intention_score_vcov
) |>
transmute(
term,
Predictor = paste0(
"Lifespan compared with alternatives: ",
Contrast
),
estimate,
conf_low,
conf_high
)
if (future_intention_term_tests$q_value[
future_intention_term_tests$term ==
"comparison_lifespan"
] < 0.05) {
future_intention_score_effects <- bind_rows(
future_intention_score_effects |>
filter(term != "comparison_lifespan"),
future_intention_lifespan_score_contrasts
)
} else {
future_intention_score_effects <-
future_intention_score_effects |>
filter(term != "comparison_lifespan")
}
fit_future_threshold_model <- function(outcome_name) {
model_data <- prepare_behavioural_model_data(
future_intention_outcome_data |>
select(
respondent_id,
all_of(outcome_name)
),
outcome_name,
behavioural_predictor_names
)
model_formula <- reformulate(
future_intention_terms,
response = outcome_name
)
model <- survey::svyglm(
model_formula,
design = make_analysis_design(model_data),
family = quasibinomial()
)
robust_vcov <- vcov(model)
contrasts <- planned_lifespan_contrasts(
model,
model_data,
type = "response",
vcov = robust_vcov
)
list(
data = model_data,
model = model,
vcov = robust_vcov,
contrasts = contrasts
)
}
future_reduce_model <- fit_future_threshold_model(
"reduce_or_stop"
)
future_increase_model <- fit_future_threshold_model(
"increase_purchases"
)
future_shorter_score <-
future_intention_lifespan_score_contrasts |>
filter(str_detect(
Predictor,
fixed("Shorter vs non-shorter")
))
future_greater_score <-
future_intention_lifespan_score_contrasts |>
filter(str_detect(
Predictor,
fixed("Greater vs non-greater")
))
future_shorter_reduce <-
future_reduce_model$contrasts |>
filter(Contrast == "Shorter vs non-shorter")
future_greater_reduce <-
future_reduce_model$contrasts |>
filter(Contrast == "Greater vs non-greater")
future_greater_increase <-
future_increase_model$contrasts |>
filter(Contrast == "Greater vs non-greater")
future_intention_diagnostics <- tibble(
Check = c(
"Complete responses",
"Answer scale",
"Uncertainty",
"Predictor overlap",
"Cross-check"
),
Result = c(
format_count(nrow(
future_intention_score_data
)),
"Six levels coded 0–5",
"Included",
paste0(
"Low (",
number(
maximum_coefficient_vif(
future_intention_score_model
),
accuracy = 0.01
),
")"
),
"Less, much less or stop"
),
Interpretation = c(
"Previous buyers with all required answers",
"Higher scores mean intending to buy more",
"Ranges show uncertainty around each result",
"The factors remain distinct",
"A separate yes/no model confirms the main result"
)
)
future_intention_global_test <- survey::regTermTest(
future_intention_score_model,
reformulate(future_intention_terms),
method = "Wald"
)
future_intention_global_fit <- tibble(
Model = "Future buying intention",
N = nrow(future_intention_score_data),
Wald = as.numeric(
survey_test_statistic(future_intention_global_test)
),
df = as.numeric(future_intention_global_test$df),
p_value = as.numeric(future_intention_global_test$p)
)
```
The six answers are treated as an ordered continuum from 0 (*I intend to stop*) to 5 (*Much more*). A lower score therefore represents an intention to purchase less in the future.
Perceiving a shorter lifespan corresponds to a `r number(abs(future_shorter_score$estimate), accuracy = 0.01)` points lower than for respondents not reporting a shorter lifespan. It is also associated with an increase of `r number(abs(future_shorter_reduce$estimate) * 100, accuracy = 0.1)` percentage points in the probability of intending to buy less or stop.
The following visualisation shows factors driving intention.
For scored questions such as attitudes and satisfaction, each bar shows a typical increase.
The lifespan contrasts compare shorter with all non-shorter responses and greater with all non-greater responses.
Negative values indicate lower future purchasing intentions; positive values indicate higher intentions.
::: {.callout-note collapse="true" title="Show tables and assumptions"}
```{r}
#| label: tbl-future-intention-global-fit
#| tbl-cap: "Global fit of the future-intention model"
future_intention_global_fit |>
model_fit_gt()
```
```{r}
#| label: tbl-future-intention-model-overview
#| tbl-cap: "Overall contribution of predictors to the future-intention model"
future_intention_overview |>
mutate(
p_value = format.pval(
p_value,
digits = 2,
eps = 0.001
),
q_value = format.pval(
q_value,
digits = 2,
eps = 0.001
)
) |>
gt() |>
fmt_number(
columns = Wald,
decimals = 1
) |>
cols_label(
Wald = "Overall test",
df = "df",
p_value = "p-value",
q_value = "Corrected p"
) |>
cols_width(
Predictor ~ pct(46),
Wald ~ px(145)
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()
```
Positive coefficients indicate a higher intended purchasing score. Country results are included in the model but not shown in the table.
```{r}
#| label: tbl-future-intention-coefficients
#| tbl-cap: "Coefficient estimates for the future-intention model"
model_coefficient_data(
future_intention_score_model,
robust_vcov = future_intention_score_vcov
) |>
coefficient_gt()
```
The answers run from stopping purchases to buying much more. The score model gives a clear overall direction, while a separate yes/no check confirms the main result for people who plan to buy less or stop.
```{r}
#| label: tbl-future-intention-diagnostics
#| tbl-cap: "Assumption and diagnostic checks for the future-intention model"
future_intention_diagnostics |>
gt() |>
cols_width(
Check ~ pct(25),
Result ~ pct(25),
Interpretation ~ pct(50)
) |>
gt_bellegarde()
```
:::
```{r}
#| label: fig-future-intention-marginal-effects
#| cache: false
#| fig-cap: !expr figure_caption("Factors linked with future buying intention", paste0("Seeing fast tech as short-lived is linked with a ", number(abs(future_shorter_score$estimate), accuracy = 0.01), "-point lower score on the 0–5 scale."))
#| fig-height: 8.5
future_intention_score_effects |>
mutate(
Predictor = fct_reorder(
str_wrap(Predictor, 42),
estimate
)
) |>
ggplot(aes(
x = estimate,
y = Predictor
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_errorbar(
aes(
xmin = conf_low,
xmax = conf_high
),
orientation = "y",
width = 0,
linewidth = bg_lw_line,
colour = bg_accent
) +
geom_point(
size = bg_size_label / .pt,
colour = bg_accent
) +
scale_x_continuous(
labels = label_number(
accuracy = 0.05,
style_positive = "plus"
)
) +
labs(
x = paste0(
"Change in intention score ",
"(0 = stop; 5 = much more)"
),
y = NULL
)
```
::: {.parametre}
<div class="parametre-title">Top 3 factors</div>
1. **Lifespan compared with alternatives.** Respondents perceiving fast tech as having a shorter lifespan intend more to purchase less or stop.
2. **Support for buying fewer items.** Stronger support for buying fewer devices corresponds to lower future purchasing intentions.
3. **Attitude towards buying.** More favourable attitudes towards fast tech buying go with higher future purchasing intentions.
:::
<!-- Commented out on purpose, there's no sense linking those factors with disposal methods
## End-of-life disposal methods
There are five main end-of-use situations:
- keeping devices,
- Household-waste disposal,
- Plastic-waste-bin disposal,
- Recycling centre,
- In-store recycling point.
They are modelled separately.
Overall, it is difficult to interpret the influence of the factors on those disposal methods.
```{r}
#| label: disposal-pathway-models-data
#| cache: true
fit_binary_pathway_model <- function(
outcome_data,
outcome_name
) {
variables <- behavioural_predictor_names
model_data <- prepare_behavioural_model_data(
outcome_data,
outcome_name,
variables
) |>
mutate(
expected_lifespan_longer = factor(
if_else(
expected_lifespan == "Longer lifespan",
"Longer lifespan",
"Not longer"
),
levels = c("Not longer", "Longer lifespan")
)
) |>
droplevels()
terms <- build_behavioural_model_terms(variables)
terms[terms == "expected_lifespan"] <-
"expected_lifespan_longer"
model_formula <- reformulate(
terms,
response = outcome_name
)
model <- survey::svyglm(
model_formula,
design = make_analysis_design(model_data),
family = quasibinomial()
)
model_tests <- survey_wald_term_tests(
model,
terms
)
effect_variables <- model_tests |>
filter(
q_value < 0.05,
!term %in% c("gender", "country", "age_z")
) |>
pull(term)
effects <- marginaleffects::avg_slopes(
model,
variables = effect_variables,
type = "response",
wts = model_data$analysis_weight,
vcov = vcov(model)
) |>
as.data.frame() |>
as_tibble() |>
transmute(
term,
Predictor = if_else(
contrast == "dY/dX",
unname(behavioural_predictor_labels[term]),
paste0(
unname(behavioural_predictor_labels[term]),
": ",
str_replace(contrast, " - ", " vs ")
)
),
estimate,
conf_low = conf.low,
conf_high = conf.high
)
lifespan_effects <- planned_lifespan_contrasts(
model,
model_data,
type = "response",
vcov = vcov(model)
) |>
transmute(
term,
Predictor = paste0(
"Lifespan compared with alternatives: ",
Contrast
),
estimate,
conf_low,
conf_high
)
if (model_tests$q_value[
model_tests$term == "comparison_lifespan"
] < 0.05) {
effects <- bind_rows(
effects |>
filter(term != "comparison_lifespan"),
lifespan_effects
)
} else {
effects <- effects |>
filter(term != "comparison_lifespan")
}
outcome_counts <- table(model_data[[outcome_name]])
diagnostics <- tibble(
Check = c(
"Complete-case sample",
"Outcome counts",
"Convergence",
"Maximum coefficient-level VIF",
"Finite estimates"
),
Result = c(
format_count(nrow(model_data)),
paste(
names(outcome_counts),
format_count(as.numeric(outcome_counts)),
collapse = "; "
),
if_else(model$converged, "Passed", "Failed"),
number(maximum_coefficient_vif(model), accuracy = 0.01),
if_else(
all(is.finite(coef(model)), is.finite(vcov(model))),
"Passed",
"Failed"
)
)
)
list(
data = model_data,
model = model,
tests = model_tests,
marginal_effects = effects,
diagnostics = diagnostics
)
}
disposal_method_factor <- function(x) {
factor(
case_when(
is.na(x) ~ NA_character_,
is_selected(x) ~ "Yes",
TRUE ~ "No"
),
levels = c("No", "Yes")
)
}
disposal_pathway_outcome_data <- df |>
mutate(respondent_id = row_number()) |>
transmute(
respondent_id,
kept = disposal_method_factor(`I kept them`),
household_waste = disposal_method_factor(
`I threw them in general household waste`
),
plastic_waste = disposal_method_factor(
`I threw them in the plastic waste bin`
),
recycling_centre = disposal_method_factor(
`I brought them to a recycling centre / municipal waste facility`
),
in_store_recycling = disposal_method_factor(
`I brought them to a recycling point in a store`
)
)
disposal_pathway_labels <- c(
kept = "Keeping devices",
household_waste = "Household waste",
plastic_waste = "Plastic-waste bin",
recycling_centre = "Recycling centre",
in_store_recycling = "In-store recycling"
)
disposal_pathway_results <- map(
names(disposal_pathway_labels),
~ fit_binary_pathway_model(
disposal_pathway_outcome_data,
.x
)
) |>
set_names(names(disposal_pathway_labels))
disposal_pathway_global_fit <- imap_dfr(
disposal_pathway_results,
~ survey_binary_model_fit(
.x$model,
unname(disposal_pathway_labels[.y]),
.x$tests$term
)
)
disposal_pathway_tests <- imap_dfr(
disposal_pathway_results,
~ .x$tests |>
mutate(
Pathway = unname(disposal_pathway_labels[.y]),
Predictor = unname(behavioural_predictor_labels[term])
)
)
disposal_pathway_effects <- imap_dfr(
disposal_pathway_results,
~ .x$marginal_effects |>
mutate(
Pathway = unname(disposal_pathway_labels[.y])
)
)
disposal_pathway_top_factors <- disposal_pathway_effects |>
left_join(
disposal_pathway_tests |>
select(Pathway, term, q_value),
by = c("Pathway", "term")
) |>
filter(
q_value < 0.05,
conf_low > 0 | conf_high < 0
) |>
group_by(Pathway, term) |>
slice_max(abs(estimate), n = 1, with_ties = FALSE) |>
ungroup() |>
group_by(Pathway) |>
slice_max(abs(estimate), n = 3, with_ties = FALSE) |>
mutate(
Pathway = factor(
Pathway,
levels = c(
"Keeping devices",
"Household waste",
"Plastic-waste bin",
"Recycling centre",
"In-store recycling"
)
)
) |>
arrange(Pathway, desc(abs(estimate))) |>
mutate(Rank = row_number()) |>
ungroup()
```
```{r}
#| label: tbl-disposal-pathway-global-fit
#| tbl-cap: "Global fit of the disposal-method models"
disposal_pathway_global_fit |>
model_fit_gt()
```
> **Note:** Each row describes a separate binary model because respondents could select more than one disposal method. Pseudo-R² values therefore compare fit within a pathway and should not be read as shares of disposal behaviour explained.
```{r}
#| label: tbl-disposal-pathway-model-overview
#| tbl-cap: "Overall contribution of predictors to the disposal-method models"
disposal_pathway_tests |>
select(Pathway, Predictor, Wald, df, q_value) |>
arrange(Pathway, q_value) |>
gt(groupname_col = "Pathway") |>
fmt_number(columns = Wald, decimals = 1) |>
fmt_number(columns = q_value, decimals = 3) |>
cols_label(
Wald = "Overall test",
df = "df",
q_value = "Corrected p"
) |>
cols_width(
Predictor ~ pct(38),
Wald ~ px(105)
) |>
tab_style(
style = cell_text(weight = "600"),
locations = cells_body(columns = Predictor)
) |>
gt_bellegarde()
```
::: {.callout-note collapse="true" title="Show coefficient estimates"}
Positive coefficients indicate a higher probability of selecting the disposal method. Country coefficients are omitted from display but remain in every model.
```{r}
#| label: tbl-disposal-pathway-coefficients
#| tbl-cap: "Coefficient estimates for the disposal-method models"
imap_dfr(
disposal_pathway_results,
~ model_coefficient_data(
.x$model,
pathway = unname(disposal_pathway_labels[.y])
)
) |>
coefficient_gt()
```
:::
```{r}
#| label: fig-disposal-pathway-marginal-effects
#| cache: false
#| fig-cap: !expr figure_caption("Factors linked with end-of-life disposal methods", "Each estimate shows the change in the probability of selecting that method; respondents may select more than one method.")
#| fig-height: 18
disposal_pathway_effects |>
mutate(
Predictor = str_wrap(Predictor, 42),
Pathway = factor(
Pathway,
levels = c(
"Keeping devices",
"Household waste",
"Plastic-waste bin",
"Recycling centre",
"In-store recycling"
)
)
) |>
ggplot(aes(
x = estimate,
y = fct_reorder(Predictor, estimate),
colour = Pathway
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline,
linetype = "dashed"
) +
geom_errorbarh(
aes(xmin = conf_low, xmax = conf_high),
height = 0,
linewidth = bg_lw_line
) +
geom_point(size = bg_point_size) +
scale_colour_manual(
values = c(
"Keeping devices" = bg_ink_54,
"Household waste" = bg_pink,
"Plastic-waste bin" = bg_pink_50,
"Recycling centre" = bg_accent,
"In-store recycling" = bg_accent_50
),
guide = "none"
) +
scale_x_continuous(
labels = label_percent(
accuracy = 0.1,
style_positive = "plus"
)
) +
facet_wrap(
~Pathway,
ncol = 1,
scales = "free_y"
) +
labs(
x = "Average change in predicted probability",
y = NULL
)
```
> **Note:** For scored questions, each bar shows a typical increase. The lifespan bars compare each highlighted answer with the other answers. These links do not prove cause and effect. The plastic-waste-bin model excludes Switzerland because that response option was not displayed there.
```{r}
#| label: disposal-pathway-top-factors
#| results: asis
#| echo: false
cat("::: {.parametre}\n")
cat("<div class=\"parametre-title\">Top 3 factors by disposal method</div>\n\n")
for (pathway_name in levels(disposal_pathway_top_factors$Pathway)) {
cat(
"<div class=\"parametre-subtitle\">",
pathway_name,
"</div>\n\n",
sep = ""
)
pathway_rows <- disposal_pathway_top_factors |>
filter(Pathway == pathway_name)
for (row_index in seq_len(nrow(pathway_rows))) {
current_row <- pathway_rows[row_index, ]
direction <- if_else(
current_row$estimate >= 0,
"a higher probability",
"a lower probability"
)
cat(
row_index,
". **",
current_row$Predictor,
"**. Associated with ",
direction,
" of selecting ",
str_to_lower(pathway_name),
" (",
percent(
abs(current_row$estimate),
accuracy = 0.1
),
" difference).\n",
sep = ""
)
}
cat("\n")
}
cat(":::\n")
```
::: {.callout-note .assumption-check collapse="true" title="Show assumption checks"}
The five models keep the fact that respondents could select several methods. They use the same factors as the other behavioural models and allow for country differences. Missing answers are excluded one method at a time; Swiss respondents are not treated as having rejected the plastic-waste-bin option.
```{r}
#| label: tbl-disposal-pathway-diagnostics
#| tbl-cap: "Assumption and diagnostic checks for the disposal-method models"
imap_dfr(
disposal_pathway_results,
~ .x$diagnostics |>
mutate(Pathway = unname(disposal_pathway_labels[.y]))
) |>
select(Pathway, Check, Result) |>
gt(groupname_col = "Pathway") |>
gt_bellegarde()
```
:::
-->
# Comparative profiles
A **comparative profile** tests which observed characteristics distinguish one group of respondents over one outcome. It uses a deliberately small set of characteristics to produce easy-to-grasp audience groups associated with a defined behaviour. This approach doesn't establishe causality.
```{r}
#| label: profiles-segments-common-data
#| cache: true
profile_required_packages <- c(
"car",
"marginaleffects",
"rpart"
)
profile_missing_packages <- profile_required_packages[
!map_lgl(
profile_required_packages,
requireNamespace,
quietly = TRUE
)
]
if (length(profile_missing_packages) > 0) {
stop(
"Install the following packages before rendering: ",
paste(profile_missing_packages, collapse = ", ")
)
}
profile_age_levels <- c(
"18–29",
"30–39",
"40–54",
"55–64",
"65+"
)
profile_household_levels <- c(
"1",
"2",
"3",
"4",
"5+"
)
profile_education_levels <- c(
"No formal education / Primary school",
"Secondary school / High school",
"College / Undergraduate",
"Bachelor or equivalent",
"Master or equivalent / Doctorate, Ph.D. or equivalent"
)
comparative_profile_data <- df |>
mutate(
respondent_id = row_number()
) |>
transmute(
respondent_id,
analysis_weight,
buyer_status = factor(
if_else(Buyer == "Buyer", "Buyer", "Non-buyer"),
levels = c("Non-buyer", "Buyer")
),
age = Age,
age_group = cut(
Age,
breaks = c(-Inf, 29, 39, 54, 64, Inf),
labels = profile_age_levels
),
age_category = as.numeric(cut(
Age,
breaks = c(-Inf, 29, 39, 54, 64, Inf),
labels = FALSE
)),
household_size = as.numeric(Nb_household),
household_group = cut(
as.numeric(Nb_household),
breaks = c(-Inf, 1, 2, 3, 4, Inf),
labels = profile_household_levels
),
household_category = as.numeric(cut(
as.numeric(Nb_household),
breaks = c(-Inf, 1, 2, 3, 4, Inf),
labels = FALSE
)),
gender = factor(
if_else(
Gender %in% sociodemographic_gender_levels,
Gender,
NA_character_
),
levels = sociodemographic_gender_levels
),
education = factor(
if_else(
Education == "Prefer not to say",
NA_character_,
Education
),
levels = profile_education_levels
),
employment = factor(
if_else(
Employment == "Prefer not to say",
NA_character_,
Employment
),
levels = sociodemographic_employment_levels
),
satisfaction_score = as.numeric(
Satisfaction_recoded
),
expected_lifespan = factor(
Global_Lifespan,
levels = c(
"Expected lifespan",
"Shorter lifespan",
"Longer lifespan"
)
),
comparison_lifespan = factor(
Comparison_Lifespan,
levels = c(
"Similar lifespan",
"Shorter lifespan",
"Greater lifespan"
)
),
attitude_score = as.numeric(
Attitude_buying_recoded
),
injunctive_score = as.numeric(
.data[["Injunctive norm_recoded"]]
),
dynamic_score = as.numeric(
.data[["Dynamic norm_recoded"]]
),
buy_fewer_score = as.numeric(
.data[["Consumers should buy fewer_recoded"]]
),
regulation_score = as.numeric(
.data[["Governments should more strictly regulate_recoded"]]
),
country = factor(
Country,
levels = model_country_levels
)
)
profile_term_labels <- c(
age = "Age",
age_group = "Age group",
age_category = "Age category",
household_group = "Household size",
household_category = "Household-size category",
gender = "Gender",
education = "Education",
employment = "Employment status",
satisfaction_score = "Device satisfaction",
expected_lifespan = "Expected lifespan",
comparison_lifespan = "Lifespan versus alternatives",
attitude_score = "Attitude towards buying fast tech",
injunctive_score = "Perceived injunctive norm",
dynamic_score = "Perceived dynamic norm",
buy_fewer_score = "Consumers should buy fewer items",
regulation_score = "Support for stricter regulation",
country = "Country"
)
maximum_predictor_overlap <- function(model) {
vif_result <- suppressWarnings(car::vif(model))
if (is.matrix(vif_result)) {
if ("GVIF^(1/(2*Df))" %in% colnames(vif_result)) {
return(max(
vif_result[, "GVIF^(1/(2*Df))"],
na.rm = TRUE
))
}
return(max(
vif_result[, "GVIF"] ^
(1 / (2 * vif_result[, "Df"])),
na.rm = TRUE
))
}
max(sqrt(vif_result), na.rm = TRUE)
}
fit_comparative_profile <- function(
data,
outcome,
terms,
contrast_specifications,
linearity_variables = character()
) {
model_data <- data |>
select(all_of(c(outcome, terms)), analysis_weight) |>
drop_na()
model_formula <- reformulate(
terms,
response = outcome
)
model <- survey::svyglm(
model_formula,
design = make_analysis_design(model_data),
family = quasibinomial()
)
global_tests <- survey_wald_term_tests(
model,
terms
) |>
mutate(
Predictor = unname(profile_term_labels[term]),
significant = q_value < 0.05
)
probability_contrasts <- pmap_dfr(
contrast_specifications,
function(
variable,
low,
high,
Predictor,
Domain,
contrast_type
) {
if (contrast_type == "continuous") {
continuous_contrast <- if (variable == "age") {
marginaleffects::avg_comparisons(
model,
variables = setNames(list(10), variable),
type = "response",
wts = model_data$analysis_weight,
vcov = vcov(model)
)
} else {
marginaleffects::avg_slopes(
model,
variables = variable,
type = "response",
wts = model_data$analysis_weight,
vcov = vcov(model)
)
}
return(
continuous_contrast |>
as.data.frame() |>
as_tibble() |>
transmute(
variable,
Predictor,
Domain,
contrast = if_else(
variable == "age",
"Ten-year increase",
if_else(
variable == "household_category",
"One-category increase",
"One-point increase"
)
),
estimate,
conf_low = conf.low,
conf_high = conf.high,
p_value = p.value
)
)
}
marginaleffects::avg_comparisons(
model,
variables = setNames(
list(c(low, high)),
variable
),
type = "response",
wts = model_data$analysis_weight,
vcov = vcov(model)
) |>
as.data.frame() |>
as_tibble() |>
transmute(
variable,
Predictor,
Domain,
contrast = paste0(high, " vs ", low),
estimate,
conf_low = conf.low,
conf_high = conf.high,
p_value = p.value
)
}
) |>
mutate(
q_value = p.adjust(p_value, method = "BH")
)
outcome_counts <- table(model_data[[outcome]])
predictor_cells <- map_int(
terms,
function(term) {
cells <- table(
model_data[[outcome]],
model_data[[term]]
)
min(cells[cells > 0])
}
)
fitted_probabilities <- fitted(model)
observed_binary <- as.numeric(model_data[[outcome]]) - 1
tjur_r2 <-
weighted.mean(
fitted_probabilities[observed_binary == 1],
model_data$analysis_weight[observed_binary == 1]
) -
weighted.mean(
fitted_probabilities[observed_binary == 0],
model_data$analysis_weight[observed_binary == 0]
)
influential_threshold <- 4 / nrow(model_data)
influential_n <- sum(
cooks.distance(model) > influential_threshold,
na.rm = TRUE
)
linearity_checks <- if (
length(linearity_variables) == 0
) {
tibble(
term = character(),
Predictor = character(),
Wald = numeric(),
df = numeric(),
p_value = numeric(),
q_value = numeric(),
Result = character()
)
} else {
map_dfr(
linearity_variables,
function(variable) {
flexible_formula <- update(
model_formula,
as.formula(
paste0(
". ~ . - ",
variable,
" + factor(",
variable,
")"
)
)
)
flexible_model <- survey::svyglm(
flexible_formula,
design = make_analysis_design(model_data),
family = quasibinomial()
)
comparison <- stats::anova(
model,
flexible_model,
method = "Wald",
test = "F",
force = TRUE
)
tibble(
term = variable,
Wald = as.numeric(comparison$Ftest),
df = as.numeric(comparison$df),
p_value = as.numeric(comparison$p)
)
}
) |>
mutate(
q_value = p.adjust(
p_value,
method = "BH"
),
Predictor = unname(
profile_term_labels[term]
),
Result = if_else(
q_value < 0.05,
"Evidence of non-linearity",
"No evidence of non-linearity"
)
) |>
select(
term,
Predictor,
Wald,
df,
p_value,
q_value,
Result
)
}
diagnostics <- tibble(
Check = c(
"Complete-case sample",
"Outcome counts",
"Convergence",
"Finite coefficients",
"Predictor overlap",
"Smallest non-empty outcome × predictor cell",
"Predictor specification",
"Continuous-scale linearity check",
"Observations with Cook's distance > 4/N",
"Tjur's coefficient of discrimination"
),
Result = c(
format_count(nrow(model_data)),
paste(
names(outcome_counts),
format_count(as.numeric(outcome_counts)),
collapse = "; "
),
if_else(model$converged, "Passed", "Failed"),
if_else(
all(is.finite(coef(model))),
"Passed",
"Failed"
),
number(maximum_predictor_overlap(model), accuracy = 0.01),
format_count(min(predictor_cells)),
paste0(
"Age entered in years; household size entered as an ",
"ordered category score; lifespan and other ",
"socio-demographic variables categorical; rating ",
"scales continuous"
),
paste0(
sum(linearity_checks$q_value < 0.05),
" of ",
nrow(linearity_checks),
" scales show evidence of non-linearity"
),
paste0(
format_count(influential_n),
" (",
format_percent(influential_n / nrow(model_data)),
")"
),
number(tjur_r2, accuracy = 0.001)
)
)
list(
data = model_data,
model = model,
global_tests = global_tests,
probability_contrasts = probability_contrasts,
diagnostics = diagnostics,
linearity_checks = linearity_checks
)
}
profile_linearity_table <- function(results) {
results$linearity_checks |>
select(
Predictor,
Wald,
df,
q_value,
Result
) |>
gt() |>
cols_label(
Predictor = "Continuous scale",
Wald = "Overall test",
df = "df",
q_value = "Corrected p",
Result = "Assessment"
) |>
fmt_number(columns = Wald, decimals = 1) |>
fmt_number(columns = q_value, decimals = 3) |>
tab_style(
style = cell_text(weight = "bold"),
locations = cells_body(
rows = q_value < 0.05
)
) |>
gt_bellegarde()
}
profile_global_test_table <- function(results) {
results$global_tests |>
arrange(desc(significant), q_value) |>
select(Predictor, Wald, df, q_value) |>
gt() |>
cols_label(
Predictor = "Predictor",
Wald = "Overall test",
df = "df",
q_value = "Corrected p"
) |>
fmt_number(columns = Wald, decimals = 1) |>
fmt_number(columns = q_value, decimals = 3) |>
sub_missing(columns = q_value, missing_text = "—") |>
tab_style(
style = cell_text(weight = "bold"),
locations = cells_body(
rows = q_value < 0.05
)
) |>
gt_bellegarde()
}
plot_profile_contrasts <- function(results, positive_label) {
results$probability_contrasts |>
mutate(
Predictor_plot = paste0(Predictor, "\n", contrast),
Domain = factor(
Domain,
levels = c(
"Socio-demographic characteristics",
"Perceptions and behavioural factors"
)
)
) |>
ggplot(aes(
x = estimate,
y = fct_reorder(Predictor_plot, estimate)
)) +
geom_vline(
xintercept = 0,
colour = bg_ink_32,
linewidth = bg_lw_hairline
) +
geom_errorbarh(
aes(xmin = conf_low, xmax = conf_high),
height = 0,
colour = bg_pink,
linewidth = bg_lw_line
) +
geom_point(
colour = bg_pink,
size = bg_point_size
) +
scale_x_continuous(
labels = percent_format(accuracy = 1),
expand = expansion(mult = c(0.08, 0.08))
) +
facet_grid(
Domain ~ .,
scales = "free_y",
space = "free_y"
) +
labs(
x = paste0(
"Difference in probability of being a ",
positive_label
),
y = NULL
) +
theme(
panel.spacing.y = grid::unit(bg_margin_pt, "pt")
)
}
```
## Buyers and non-buyers
### Comparative profile
The following model estimates **the probability of having ever bought fast tech** and compares buyers with non-buyers on socio-demographic characteristics, lifespan perceptions, buying attitude and perceived norms.
```{r}
#| label: buyer-profile-model-data
#| cache: true
buyer_profile_terms <- c(
"age",
"household_category",
"gender",
"education",
"employment",
"comparison_lifespan",
"attitude_score",
"injunctive_score",
"dynamic_score",
"buy_fewer_score",
"regulation_score",
"country"
)
buyer_profile_contrasts <- tribble(
~variable, ~low, ~high, ~Predictor, ~Domain, ~contrast_type,
"age", NA_character_, NA_character_, "Age", "Socio-demographic characteristics", "continuous",
"household_category", NA_character_, NA_character_, "Household-size category", "Socio-demographic characteristics", "continuous",
"comparison_lifespan", "Shorter lifespan", "Greater lifespan", "Lifespan versus alternatives", "Perceptions and behavioural factors", "categorical",
"attitude_score", NA_character_, NA_character_, "Attitude towards buying fast tech", "Perceptions and behavioural factors", "continuous",
"injunctive_score", NA_character_, NA_character_, "Perceived injunctive norm", "Perceptions and behavioural factors", "continuous",
"dynamic_score", NA_character_, NA_character_, "Perceived dynamic norm", "Perceptions and behavioural factors", "continuous",
"buy_fewer_score", NA_character_, NA_character_, "Consumers should buy fewer items", "Perceptions and behavioural factors", "continuous",
"regulation_score", NA_character_, NA_character_, "Support for stricter regulation", "Perceptions and behavioural factors", "continuous"
)
buyer_profile_results <- fit_comparative_profile(
comparative_profile_data,
outcome = "buyer_status",
terms = buyer_profile_terms,
contrast_specifications = buyer_profile_contrasts,
linearity_variables = c(
"age",
"household_category",
"attitude_score",
"injunctive_score",
"dynamic_score",
"buy_fewer_score",
"regulation_score"
)
)
buyer_profile_significant <- buyer_profile_results$global_tests |>
filter(
significant,
term != "country"
) |>
pull(Predictor)
buyer_profile_significant_text <- paste(
buyer_profile_significant,
collapse = ", "
)
buyer_profile_key_effect <- buyer_profile_results$probability_contrasts |>
slice_max(
abs(estimate),
n = 1,
with_ties = FALSE
)
buyer_profile_age_effect <- buyer_profile_results$probability_contrasts |>
filter(variable == "age") |>
slice_head(n = 1)
```
The analysis includes `r format_count(nrow(buyer_profile_results$data))` respondents. It shows links, not causes. Over ten years of age, the probability of having bought fast tech is **`r percent(abs(buyer_profile_age_effect$estimate), accuracy = 0.1)` lower** (likely range: `r percent(abs(buyer_profile_age_effect$conf_high), accuracy = 0.1)` to `r percent(abs(buyer_profile_age_effect$conf_low), accuracy = 0.1)` lower).
```{r}
#| label: fig-buyer-profile-contrasts
#| fig-cap: !expr figure_caption("What distinguishes buyers from non-buyers", paste0("Over ten years of age, the probability of having bought fast tech is ", percent(abs(buyer_profile_age_effect$estimate), accuracy = 0.1), " lower."))
#| cache: false
plot_profile_contrasts(
buyer_profile_results,
positive_label = "buyer"
)
```
::: {.callout-note collapse="true" title="Show tables and assumptions"}
```{r}
#| label: tbl-buyer-profile-global-tests
#| tbl-cap: "Variables distinguishing buyers from non-buyers"
#| cache: false
profile_global_test_table(buyer_profile_results)
```
The model uses one record per person. Age, household size and the five rating scales are treated as gradual measures. Lifespan perception and the other personal characteristics are treated as categories. The results show links, not causes.
```{r}
#| label: tbl-buyer-profile-assumption-checks
#| tbl-cap: "Assumption and diagnostic checks for the buyer profile model"
#| cache: false
buyer_profile_results$diagnostics |>
gt() |>
gt_bellegarde()
```
The following tests compare each one-slope specification with a model giving every response level its own coefficient. A significant result indicates that the continuous scale remains usable as a metric score, but that a single straight-line effect may be an oversimplification.
```{r}
#| label: tbl-buyer-profile-linearity-checks
#| tbl-cap: "Linearity checks for the continuous scales in the buyer profile model"
#| cache: false
profile_linearity_table(
buyer_profile_results
)
```
The smallest cell is driven by rare response combinations, especially the non-binary gender category. Its coefficient should therefore be interpreted cautiously. Observations exceeding the Cook's-distance screening threshold are retained: this threshold flags cases for sensitivity review and is not an automatic exclusion rule.
:::
### Typical observed profiles
```{r}
#| label: buyer-typical-profiles-data
weighted_mode_with_share <- function(x, weights) {
valid <- !is.na(x) & !is.na(weights) & weights > 0
if (!any(valid)) {
return(NA_character_)
}
distribution <- tibble(
value = as.character(x[valid]),
weight = weights[valid]
) |>
group_by(value) |>
summarise(weight = sum(weight), .groups = "drop")
modal_response <- distribution |>
slice_max(weight, n = 1, with_ties = FALSE)
paste0(
modal_response$value,
" (",
percent(
modal_response$weight / sum(distribution$weight),
accuracy = 0.1
),
")"
)
}
buyer_profile_metric_spec <- tribble(
~term, ~variable, ~Characteristic, ~metric, ~suffix,
"age", "age", "Mean age", "mean", " years",
"household_category", "household_size", "Mean household size", "mean", " people",
"education", "education", "Most common education level", "mode", "",
"employment", "employment", "Most common employment status", "mode", "",
"comparison_lifespan", "comparison_lifespan", "Most common comparative-lifespan response", "mode", "",
"attitude_score", "attitude_score", "Mean buying-attitude score", "mean", "/5",
"injunctive_score", "injunctive_score", "Mean perceived-approval score", "mean", "/5",
"buy_fewer_score", "buy_fewer_score", "Mean ‘buy fewer’ score", "mean", "/5",
"regulation_score", "regulation_score", "Mean regulation-support score", "mean", "/5"
)
buyer_profile_significant_terms <-
buyer_profile_results$global_tests |>
filter(significant, term != "country") |>
pull(term)
buyer_typical_profiles <- buyer_profile_metric_spec |>
filter(term %in% buyer_profile_significant_terms) |>
pmap_dfr(function(
term,
variable,
Characteristic,
metric,
suffix
) {
comparative_profile_data |>
group_by(buyer_status) |>
summarise(
Value = if (metric == "mean") {
paste0(
number(
weighted_mean_safe(
.data[[variable]],
analysis_weight
),
accuracy = 0.1
),
suffix
)
} else {
weighted_mode_with_share(
.data[[variable]],
analysis_weight
)
},
.groups = "drop"
) |>
mutate(
term = term,
Characteristic = Characteristic
)
}) |>
left_join(
buyer_profile_metric_spec |>
mutate(metric_order = row_number()) |>
select(term, metric_order),
by = "term"
) |>
arrange(metric_order) |>
select(Characteristic, buyer_status, Value) |>
pivot_wider(
names_from = buyer_status,
values_from = Value
)
buyer_profile_mean_age <- comparative_profile_data |>
group_by(buyer_status) |>
summarise(
mean_age = weighted_mean_safe(
age,
analysis_weight
),
.groups = "drop"
) |>
pivot_wider(
names_from = buyer_status,
values_from = mean_age
)
```
This profile shows the average or most common answer in each group. Non-buyers are older on average (`r number(buyer_profile_mean_age[["Non-buyer"]], accuracy = 0.1)` years) than buyers (`r number(buyer_profile_mean_age$Buyer, accuracy = 0.1)` years). This age difference remains clear when the other characteristics are considered.
```{r}
#| label: tbl-buyer-typical-profiles
#| tbl-cap: "Typical observed profiles of buyers and non-buyers"
buyer_typical_profiles |>
gt() |>
gt_bellegarde()
```
### Behavioural profiles by buying status
The figure below isolates the main characteristic in the buying profile: attitude towards buying fast tech. It shows the probability of belonging to the buyer group at each of the five response levels. This is an observed gradient, not a causal effect of changing attitude.
```{r}
#| label: buyer-action-profiles-data
#| cache: false
format_action_tree_path <- function(path) {
conditions <- path |>
tail(-1)
if (length(conditions) == 0) {
return("All eligible respondents")
}
parsed_conditions <- str_remove_all(
conditions,
"\\s+"
) |>
str_match(
"^(age|attitude_score|injunctive_score)(>=|<)([0-9.]+)$"
) |>
as_tibble(
.name_repair = ~ c(
"condition",
"variable",
"operator",
"threshold"
)
) |>
mutate(
condition_order = row_number(),
threshold = as.numeric(threshold)
) |>
filter(!is.na(variable))
variable_labels <- c(
age = "Age",
attitude_score = "Buying attitude",
injunctive_score = "Perceived approval"
)
parsed_conditions |>
group_by(variable) |>
summarise(
condition_order = min(condition_order),
lower = if (any(operator == ">=")) {
max(threshold[operator == ">="])
} else {
-Inf
},
upper = if (any(operator == "<")) {
min(threshold[operator == "<"])
} else {
Inf
},
.groups = "drop"
) |>
arrange(condition_order) |>
mutate(
lower_display = ceiling(lower),
upper_display = floor(upper - 1e-8),
label = case_when(
is.finite(lower) & is.finite(upper) ~ paste0(
variable_labels[variable],
" ",
lower_display,
"–",
upper_display
),
is.finite(lower) ~ paste0(
variable_labels[variable],
" ≥ ",
lower_display
),
is.finite(upper) ~ paste0(
variable_labels[variable],
" ≤ ",
upper_display
)
)
) |>
pull(label) |>
paste(collapse = " and ")
}
fit_action_tree <- function(
data,
outcome,
seed = 20260805
) {
model_data <- data |>
select(
all_of(outcome),
age,
attitude_score,
injunctive_score,
analysis_weight
) |>
drop_na() |>
mutate(
outcome_numeric = as.integer(
.data[[outcome]] == "Yes"
)
)
set.seed(seed)
tree <- rpart::rpart(
outcome_numeric ~
age +
attitude_score +
injunctive_score,
data = model_data,
weights = analysis_weight,
method = "anova",
control = rpart::rpart.control(
maxdepth = 2,
minbucket = max(
100,
floor(0.05 * nrow(model_data))
),
cp = 0.001,
xval = 10
)
)
cp_table <- tree$cptable
best_row <- which.min(cp_table[, "xerror"])
selected_cp <- cp_table[best_row, "CP"]
tree <- rpart::prune(
tree,
cp = selected_cp
)
leaf_nodes <- as.integer(
row.names(tree$frame)[
tree$frame$var == "<leaf>"
]
)
tree_paths <- rpart::path.rpart(
tree,
nodes = leaf_nodes,
print.it = FALSE
)
profile_labels <- map_chr(
tree_paths,
format_action_tree_path
)
names(profile_labels) <- as.character(leaf_nodes)
profiles <- model_data |>
mutate(
leaf_node = row.names(tree$frame)[tree$where],
Profile = profile_labels[leaf_node]
) |>
group_by(Profile) |>
summarise(
N = n(),
weighted_N = sum(analysis_weight),
outcome_rate = weighted_mean_safe(
outcome_numeric,
analysis_weight
),
.groups = "drop"
) |>
arrange(outcome_rate) |>
mutate(
Profile = factor(Profile, levels = Profile)
)
node_numbers <- as.integer(row.names(tree$frame))
tree_depth <- max(
floor(log(node_numbers, base = 2))
)
list(
data = model_data,
tree = tree,
profiles = profiles,
diagnostics = tibble(
Check = c(
"Complete cases",
"Terminal profiles",
"Maximum tree depth",
"Smallest profile",
"Cross-validation"
),
Result = c(
format_count(nrow(model_data)),
nrow(profiles),
tree_depth,
format_count(min(profiles$N)),
"Used to choose tree size"
)
)
)
}
plot_action_tree_profiles <- function(
tree_results,
x_label
) {
tree_results$profiles |>
ggplot(aes(
x = outcome_rate,
y = Profile,
fill = outcome_rate
)) +
geom_col(width = bg_bar_width) +
geom_text(
aes(label = percent(
outcome_rate,
accuracy = 0.1
)),
hjust = -0.12,
size = bg_size_label / .pt
) +
scale_fill_bg_c(
palette = "seq",
guide = "none"
) +
scale_x_continuous(
labels = percent_format(),
breaks = c(0, 0.25, 0.50, 0.75, 1),
expand = expansion(mult = c(0, 0.02))
) +
scale_y_discrete(
labels = \(x) str_wrap(x, width = 38)
) +
coord_cartesian(
xlim = c(0, 1.12),
clip = "off"
) +
labs(
x = x_label,
y = NULL
)
}
buyer_action_profile_data <- comparative_profile_data |>
transmute(
buyer = factor(
if_else(
buyer_status == "Buyer",
"Yes",
"No"
),
levels = c("No", "Yes")
),
age,
attitude_score,
injunctive_score,
analysis_weight
)
buyer_attitude_design <- survey::svydesign(
ids = ~1,
weights = ~analysis_weight,
data = buyer_action_profile_data |>
mutate(buyer_numeric = as.numeric(buyer == "Yes"))
)
buyer_attitude_probability <- survey::svyby(
~buyer_numeric,
~attitude_score,
design = buyer_attitude_design,
FUN = survey::svymean,
vartype = "ci",
na.rm = TRUE
) |>
as.data.frame() |>
as_tibble() |>
transmute(
Attitude = factor(
attitude_score,
levels = 1:5,
labels = c(
"Completely unfavourable",
"Rather unfavourable",
"Neither favourable nor unfavourable",
"Rather favourable",
"Completely favourable"
)
),
probability = buyer_numeric,
conf_low = ci_l,
conf_high = ci_u
)
```
The probability of being a previous buyer rises from **`r percent(first(buyer_attitude_probability$probability), accuracy = 0.1)`** among respondents completely unfavourable towards buying to **`r percent(last(buyer_attitude_probability$probability), accuracy = 0.1)`** among those completely favourable.
```{r}
#| label: fig-buyer-probability-attitude
#| cache: false
#| fig-cap: !expr figure_caption("Probability of having bought fast tech by buying attitude", paste0("The buyer share rises from ", percent(first(buyer_attitude_probability$probability), accuracy = 0.1), " among completely unfavourable respondents to ", percent(last(buyer_attitude_probability$probability), accuracy = 0.1), " among completely favourable respondents."))
#| fig-height: 5.8
buyer_attitude_probability |>
ggplot(aes(x = Attitude, y = probability, group = 1)) +
geom_errorbar(
aes(ymin = conf_low, ymax = conf_high),
width = 0.12,
colour = bg_accent_50,
linewidth = bg_lw_line
) +
geom_line(
colour = bg_accent,
linewidth = bg_lw_line
) +
geom_point(
colour = bg_accent,
fill = bg_surface,
shape = 21,
stroke = bg_lw_line,
size = 3.2
) +
geom_text(
aes(label = percent(probability, accuracy = 0.1)),
vjust = -0.9,
size = bg_size_label / .pt
) +
scale_y_continuous(
labels = percent_format(),
limits = c(0, 1),
expand = expansion(mult = c(0, 0.06))
) +
scale_x_discrete(labels = \(x) str_wrap(x, width = 18)) +
labs(
x = "Attitude towards buying fast tech",
y = "Probability of belonging to the buyer group"
)
```
## Behavioural profiles by disposal method
Here's a split by age to identify profiles associated with each concrete disposal method.
```{r}
#| label: disposal-action-profiles-data
#| cache: false
disposal_action_profile_data <-
disposal_pathway_outcome_data |>
left_join(
comparative_profile_data |>
select(
respondent_id,
age,
attitude_score,
injunctive_score,
analysis_weight
),
by = "respondent_id"
)
disposal_action_trees <- imap(
disposal_pathway_labels,
~ fit_action_tree(
disposal_action_profile_data,
outcome = .y,
seed = 20260810 + match(
.y,
names(disposal_pathway_labels)
)
)
)
disposal_action_tree_profiles <- imap_dfr(
disposal_action_trees,
~ .x$profiles |>
mutate(
Pathway = unname(disposal_pathway_labels[.y]),
Profile_text = as.character(Profile)
)
) |>
arrange(Pathway, outcome_rate) |>
mutate(
Profile_plot = paste(
Pathway,
Profile_text,
sep = "___"
),
Profile_plot = factor(
Profile_plot,
levels = unique(Profile_plot)
),
Pathway = factor(
Pathway,
levels = c(
"Recycling centre",
"In-store recycling",
"Household waste",
"Plastic-waste bin",
"Keeping devices"
)
)
)
disposal_action_tree_extremes <-
disposal_action_tree_profiles |>
group_by(Pathway) |>
summarise(
lowest_rate = min(outcome_rate),
highest_rate = max(outcome_rate),
.groups = "drop"
)
disposal_recycling_centre_tree_extreme <-
disposal_action_tree_extremes |>
filter(Pathway == "Recycling centre")
disposal_recycling_centre_tree_low_profile <-
disposal_action_tree_profiles |>
filter(Pathway == "Recycling centre") |>
slice_min(outcome_rate, n = 1, with_ties = FALSE)
disposal_recycling_centre_tree_high_profile <-
disposal_action_tree_profiles |>
filter(Pathway == "Recycling centre") |>
slice_max(outcome_rate, n = 1, with_ties = FALSE)
disposal_kept_tree_high_profile <-
disposal_action_tree_profiles |>
filter(Pathway == "Keeping devices") |>
slice_max(outcome_rate, n = 1, with_ties = FALSE)
```
> **Note:** These profiles are exploratory and should be tested before use.
```{r}
#| label: fig-disposal-action-profiles
#| cache: false
#| fig-cap: !expr figure_caption("Action-oriented profiles for end-of-life methods", "The bars compare groups defined by age")
#| fig-height: 17
disposal_action_tree_profiles |>
ggplot(aes(
x = outcome_rate,
y = Profile_plot,
fill = Pathway
)) +
geom_col(
width = bg_bar_width,
show.legend = FALSE
) +
geom_text(
aes(label = percent(
outcome_rate,
accuracy = 0.1
)),
hjust = -0.12,
size = bg_size_label / .pt
) +
scale_fill_manual(
values = c(
"Recycling centre" = bg_accent,
"In-store recycling" = bg_accent_50,
"Household waste" = bg_pink,
"Plastic-waste bin" = bg_pink_50,
"Keeping devices" = bg_ink_54
)
) +
scale_x_continuous(
labels = percent_format(),
limits = c(0, 1),
expand = expansion(mult = c(0, 0.12))
) +
scale_y_discrete(
labels = \(x) x |>
str_remove("^[^_]+___") |>
str_wrap(width = 38)
) +
facet_wrap(
~Pathway,
ncol = 1,
scales = "free_y"
) +
labs(
x = "Observed share selecting the method",
y = NULL
)
```
::: {.callout-note .assumption-check collapse="true" title="Show assumption checks"}
Every tree is restricted to two levels and requires each terminal profile to contain at least 5% of its complete-case sample. This favours readable and adequately sized profiles. Split points remain sample-dependent, and the five trees should be validated on new data before being used for targeting.
```{r}
#| label: tbl-disposal-action-profile-checks
#| tbl-cap: "Checks for the action-oriented disposal profiles"
imap_dfr(
disposal_action_trees,
~ .x$diagnostics |>
mutate(
Pathway = unname(
disposal_pathway_labels[.y]
)
)
) |>
select(Pathway, Check, Result) |>
gt(groupname_col = "Pathway") |>
gt_bellegarde()
```
:::
## Associations between methods and motivations
```{r}
#| label: charger-method-motivation-data
#| cache: true
charger_disposal_analysis_data <- df |>
mutate(
respondent_id = row_number(),
charger_disposed = as.character(
.data[[
"12months_disposed_Charging & lighting devices"
]]
) %in% c("2", "3", "4", "5", "6")
) |>
inner_join(
disposal_response_data |>
select(respondent_id),
by = "respondent_id"
) |>
filter(charger_disposed)
method_cols_for_matrix <- disposal_method_cols[
!disposal_method_cols %in% c(
"Other...124",
"I don’t remember...125"
)
]
motivation_cols_for_matrix <- disposal_motivation_cols[
disposal_motivation_cols != "Other...132"
]
method_labels <- c(
"I kept them" = "Kept",
"I threw them in general household waste" =
"Household waste",
"I threw them in the plastic waste bin" =
"Plastic-waste bin",
"I threw them in a street bin" = "Street bin",
"I brought them to a recycling centre / municipal waste facility" =
"Recycling centre",
"I brought them to a recycling point in a store" =
"In-store recycling",
"I gave them away or donated" = "Donated",
"I sold them" = "Sold"
)
motivation_labels <- c(
"It’s the easiest solution" = "Easiest solution",
"It’s closest to home" = "Closest to home",
"It’s what I usually do with this kind of waste" =
"Usual practice",
"I did it to protect the environment" =
"Environmental protection",
"It’s what my family and friends do with that kind of waste" =
"Family and friends",
"I didn’t really think about it" = "Did not think",
"didn’t know where else to put it" =
"Did not know where else"
)
method_motivation_matrix <- map_dfr(
method_cols_for_matrix,
function(method) {
map_dfr(
motivation_cols_for_matrix,
function(motivation) {
pair <- charger_disposal_analysis_data |>
filter(
!is.na(.data[[method]]),
!is.na(.data[[motivation]])
) |>
transmute(
analysis_weight,
country = factor(
Country,
levels = model_country_levels
),
method_selected = is_selected(
.data[[method]]
),
motivation_selected = is_selected(
.data[[motivation]]
)
)
a <- weighted_sum_safe(
pair$method_selected & pair$motivation_selected,
pair$analysis_weight
)
b <- weighted_sum_safe(
pair$method_selected & !pair$motivation_selected,
pair$analysis_weight
)
pair_design <- make_analysis_design(pair)
association_model <- survey::svyglm(
motivation_selected ~ method_selected + country,
design = pair_design,
family = quasibinomial()
)
association_test <- survey::regTermTest(
association_model,
~method_selected,
method = "Wald"
)
method_coefficient <- coef(
association_model
)[["method_selectedTRUE"]]
tibble(
Method = method,
Motivation = motivation,
denominator = nrow(pair),
conditional_pct = a / (a + b),
odds_ratio = exp(method_coefficient),
p_value = as.numeric(association_test$p)
)
}
)
}
) |>
mutate(
q_value = p.adjust(
p_value,
method = "BH"
),
Method = factor(
unname(method_labels[Method]),
levels = rev(unname(method_labels))
),
Motivation = factor(
unname(motivation_labels[Motivation]),
levels = unname(motivation_labels)
),
log2_or = log2(odds_ratio),
label = percent(
conditional_pct,
accuracy = 1
)
)
method_motivation_key <- method_motivation_matrix |>
filter(q_value < 0.05) |>
slice_max(
abs(log2_or),
n = 1,
with_ties = FALSE
)
```
Each cell shows the share of people who gave the reason after choosing the method on that row. Green means the reason is more common for that method, pink means it is less common, and white means little difference. Bold percentages mark links that are unlikely to be due to chance. The comparison also allows for differences between countries.
```{r}
#| label: fig-charger-method-motivation
#| fig-cap: !expr figure_caption("Motivations associated with each reported disposal method", paste0(method_motivation_key$label, " of respondents selecting ‘", as.character(method_motivation_key$Method), "’ also selected ‘", as.character(method_motivation_key$Motivation), "’."))
#| fig-height: 6.8
method_motivation_matrix |>
ggplot(aes(
x = Motivation,
y = Method,
fill = log2_or
)) +
geom_tile(
colour = bg_surface,
linewidth = 1
) +
geom_text(
aes(
label = label,
fontface = if_else(
q_value < 0.05,
"bold",
"plain"
)
),
colour = bg_ink,
size = (bg_size_label - 1) / .pt
) +
scale_fill_gradient2(
low = bg_pink,
mid = bg_surface,
high = bg_accent,
midpoint = 0,
limits = log2(c(0.25, 4)),
oob = scales::squish,
breaks = log2(c(0.25, 1, 4)),
labels = c(
"Less",
"Similar",
"More"
),
name = "Link"
) +
labs(
x = "Reason for choosing the solution",
y = NULL
) +
theme(
axis.text.x = element_text(
angle = 35,
hjust = 1
),
panel.grid = element_blank(),
legend.position = "top"
)
```
Official recycling channels are mainly associated with established habits and environmental protection, whereas inappropriate disposal methods or keeping devices are more commonly associated with convenience, proximity or a lack of forethought.
## Buying attitude and perceived approval
```{r}
#| label: attitude-injunctive-data
attitude_response_levels <- c(
"Completely unfavourable",
"Rather unfavourable",
"Neither favourable nor unfavourable",
"Rather favourable",
"Completely favourable"
)
injunctive_response_levels <- c(
"Completely disapprove",
"Rather disapprove",
"Neither approve nor disapprove",
"Rather approve",
"Completely approve"
)
attitude_flow_labels <- c(
"Completely\nunfavourable",
"Rather\nunfavourable",
"Neither favourable\nnor unfavourable",
"Rather\nfavourable",
"Completely\nfavourable"
)
injunctive_flow_labels <- c(
"Completely\ndisapprove",
"Rather\ndisapprove",
"Neither approve\nnor disapprove",
"Rather\napprove",
"Completely\napprove"
)
attitude_injunctive_data <- df |>
transmute(
attitude_score = as.numeric(Attitude_buying_recoded),
injunctive_score = as.numeric(
.data[["Injunctive norm_recoded"]]
),
analysis_weight
) |>
drop_na() |>
mutate(
Attitude = factor(
attitude_score,
levels = 1:5,
labels = attitude_flow_labels
),
`Perceived approval` = factor(
injunctive_score,
levels = 1:5,
labels = injunctive_flow_labels
)
)
attitude_injunctive_rho <- weighted_correlation_safe(
rank(attitude_injunctive_data$attitude_score),
rank(attitude_injunctive_data$injunctive_score),
attitude_injunctive_data$analysis_weight
)
attitude_injunctive_direction <- if_else(
attitude_injunctive_rho >= 0,
"positive",
"negative"
)
attitude_injunctive_strength <- case_when(
abs(attitude_injunctive_rho) < 0.10 ~ "very weak",
abs(attitude_injunctive_rho) < 0.30 ~ "weak",
abs(attitude_injunctive_rho) < 0.50 ~ "moderate",
abs(attitude_injunctive_rho) < 0.70 ~ "strong",
TRUE ~ "very strong"
)
attitude_injunctive_flow <- attitude_injunctive_data |>
group_by(Attitude, `Perceived approval`) |>
summarise(
n = n(),
weighted_n = sum(analysis_weight),
.groups = "drop"
) |>
mutate(pct = weighted_n / sum(weighted_n))
```
Buying attitude and perceived approval show a **`r attitude_injunctive_strength` `r attitude_injunctive_direction` association** (**correlation = `r number(attitude_injunctive_rho, accuracy = 0.01)`**). Respondents who are more favourable towards buying fast tech therefore also tend to perceive greater approval from people important to them.
```{r}
#| label: fig-attitude-injunctive-flow
#| fig-cap: !expr figure_caption("Flow between buying attitude and perceived approval", "A majority of respondents who are completely favourable mainly think that people important to them completely approve of buying fast tech devices")
#| fig-height: 7
attitude_injunctive_flow |>
ggplot(aes(
axis1 = Attitude,
axis2 = `Perceived approval`,
y = weighted_n
)) +
ggalluvial::geom_alluvium(
aes(fill = Attitude),
width = 0.24,
alpha = 0.55,
knot.pos = 0.45
) +
ggalluvial::geom_stratum(
width = 0.24,
fill = bg_surface,
colour = bg_ink_32,
linewidth = bg_lw_hairline
) +
ggalluvial::stat_stratum(
geom = "text",
aes(label = after_stat(stratum)),
size = (bg_size_label - 2) / .pt,
lineheight = 0.9
) +
scale_fill_manual(
values = setNames(
bg_ramp("seq", 5),
attitude_flow_labels
),
guide = "none"
) +
scale_x_discrete(
limits = c(
"Buying attitude",
"Perceived approval"
),
expand = expansion(mult = c(0.22, 0.22))
) +
scale_y_continuous(
breaks = NULL,
labels = NULL
) +
labs(
x = NULL,
y = NULL
) +
theme(
panel.grid = element_blank()
)
```
> **Note:** Wider flows mean that the response is more common.