library(dplyr)
library(tidyr)
library(forcats)
library(ggplot2)
library(scales)
library(knitr)
library(purrr)
library(stringr)
library(colorspace)
# Cleaned data produced by clean_osmi_2016_11-11.Rmd
osmi <- readRDS("osmi_clean.rds")
codebook <- readr::read_csv("osmi_codebook.csv", show_col_types = FALSE)
pc <- function(x, accuracy = 1) percent(x, accuracy = accuracy) # short percent formatter
# One quiet theme for every chart: no chart junk, no rotated text
theme_eda <- theme_minimal(base_size = 12) +
theme(panel.grid.minor = element_blank(),
panel.grid.major.y = element_blank(),
plot.title = element_text(face = "bold"),
plot.title.position = "plot",
plot.caption.position = "plot",
plot.subtitle = element_text(colour = "grey35"),
plot.caption = element_text(colour = "grey45", hjust = 0),
axis.title.y = element_text(angle = 0, vjust = 1),
legend.position = "top",
legend.justification = "left")
theme_set(theme_eda)
# Colours
col_bad <- "#e34948" # No (red)
col_mid <- "#c9c7c1" # Maybe / Don't know (grey)
col_good <- "#2a78d6" # Yes (blue)
col_accent <- "#0d366b" # highlight in grey-plus-accent charts (dark blue)
col_muted <- "grey65" # everything that is not highlighted
col_neutral <- "#2a78d6" # plain descriptive charts
# Answer scales: No is always red (left), Yes always blue (right).
# Questions are phrased so that Yes is always the answer that supports disclosure.
pal_raise <- c(No = col_bad, Maybe = col_mid, Yes = col_good)
pal_stance <- c(No = col_bad, `Maybe / Don't know` = col_mid, Yes = col_good)
# Ordered frequency scale: one hue, light -> dark
pal_freq <- c(Never = "#86b6ef", Rarely = "#3987e5", Sometimes = "#1c5cab", Often = "#0d366b")
# TRUE for light fills, which get dark label text (keeps text contrast readable)
is_light <- function(cols) colSums(col2rgb(cols) * c(0.299, 0.587, 0.114)) > 150
# Horizontal 100% stacked bars, labelled directly (segments >= 5%), so no x axis is needed.
# df must contain: group, answer, pct
stack_plot <- function(df, palette, title, subtitle = NULL, caption = NULL) {
ggplot(df, aes(x = pct, y = group, fill = answer)) +
geom_col(width = 0.65, colour = "white", linewidth = 0.6,
position = position_stack(reverse = TRUE)) +
geom_text(aes(label = if_else(pct >= 0.05, pc(pct), ""),
colour = is_light(palette[as.character(answer)]), group = answer),
position = position_stack(vjust = 0.5, reverse = TRUE),
size = 3.4, fontface = "bold", show.legend = FALSE) +
scale_colour_manual(values = c(`TRUE` = "grey15", `FALSE` = "white"), guide = "none") +
scale_x_continuous(expand = c(0, 0)) +
scale_fill_manual(values = palette, drop = FALSE) +
labs(title = title, subtitle = subtitle, caption = caption, x = NULL, y = NULL, fill = NULL) +
theme(axis.text.x = element_blank(), panel.grid.major.x = element_blank(),
plot.margin = margin(5.5, 15, 5.5, 5.5))
}
# Count + percentages within each group, then stack_plot()
pct_stack <- function(data, group, answer, palette, ...) {
data %>%
filter(!is.na({{ answer }})) %>%
count(group = {{ group }}, answer = {{ answer }}) %>%
group_by(group) %>%
mutate(pct = n / sum(n)) %>%
ungroup() %>%
stack_plot(palette, ...)
}
# One row of the paired-comparison table: share worse / same / better, 95% CI and exact sign test
paired_row <- function(label, worse, same, better) {
n <- worse + same + better
ci <- prop.test(worse, n)$conf.int
p <- binom.test(worse, worse + better)$p.value
tibble(Comparison = label, n = n,
`Worse for mental health / when untreated` = pc(worse / n),
`95% CI` = paste0(pc(ci[1]), " to ", pc(ci[2])),
`Same answer` = pc(same / n),
`Other direction` = pc(better / n),
`Sign test p` = if_else(p < 0.001, "< 0.001", as.character(signif(p, 2))))
}
# Wilcoxon signed-rank test for two paired ordered answers (uses direction AND size of each change)
wilcox_p <- function(x, y) {
p <- wilcox.test(as.integer(x), as.integer(y), paired = TRUE, exact = FALSE)$p.value
if_else(p < 0.001, "< 0.001", as.character(signif(p, 2)))
}
answer_score <- function(x) as.integer(x) - 1 # No = 0, Maybe = 1, Yes = 2
# Within-person differences used throughout
osmi <- osmi %>%
mutate(interview_gap = answer_score(interview_physical) - answer_score(interview_mental),
consequence_gap = answer_score(consequence_mental) - answer_score(consequence_physical),
interfere_shift = as.integer(interfere_untreated) - as.integer(interfere_treated))
n_all <- nrow(osmi)
n_emp <- sum(osmi$is_employee, na.rm = TRUE)
n_both <- sum(osmi$interfere_both)
Data: OSMI Mental Health in Tech Survey 2016 (Kaggle, CC BY-SA 4.0), 1433 respondents working in tech.
Questions:
Cleaning is done in clean_osmi_2016_11-11.Rmd. Key
decisions:
| Issue | Decision |
|---|---|
| Column headers are full questions (two identical) | Short variable names; codebook kept |
| Employer questions not asked of self-employed respondents | Employer questions analysed for employees only |
| Productivity questions asked only of self-employed respondents | Not used; work-interference questions used instead |
| “Not applicable to me” in interference questions | Set to missing; analysis uses respondents who answered both |
| “Maybe” / “I don’t know” answers | Kept as a separate middle category |
| Impossible ages (3, 15, 17, 99, 323) | Age set to missing; respondent kept |
| Gender as free text (72 spellings) | Grouped by stated identity into Man / Woman / Non-binary or other; unclear answers set to missing |
| 53 countries, mostly US | Grouped into US / UK / Rest of Europe / Rest of world |
tibble(
`Analysis population` = c(
"All respondents",
"Employees (employer questions)",
"Answered both interference questions",
" ...of whom report no current disorder",
"Report a current disorder but answered N/A on interference",
"Ages set to missing"),
n = c(n_all, n_emp, n_both,
sum(osmi$flag_interfere_no_condition, na.rm = TRUE),
sum(osmi$flag_condition_but_na, na.rm = TRUE),
sum(osmi$age_flag))
) %>% kable()
| Analysis population | n |
|---|---|
| All respondents | 1433 |
| Employees (employer questions) | 1146 |
| Answered both interference questions | 860 |
| …of whom report no current disorder | 122 |
| Report a current disorder but answered N/A on interference | 41 |
| Ages set to missing | 5 |
codebook %>%
filter(variable %in% c("self_employed", "tech_company", "mh_benefits", "employer_discussed_mh",
"anonymity_protected", "consequence_mental", "consequence_physical",
"comfortable_coworkers", "comfortable_supervisor", "employer_mh_serious",
"interview_physical", "interview_mental", "hurt_career",
"coworkers_view_negative", "mh_disorder_current", "sought_treatment",
"interfere_treated", "interfere_untreated",
"age", "gender", "country_live", "remote")) %>%
kable(col.names = c("Variable", "Original survey question"))
| Variable | Original survey question |
|---|---|
| self_employed | Are you self-employed? |
| tech_company | Is your employer primarily a tech company/organization? |
| mh_benefits | Does your employer provide mental health benefits as part of healthcare coverage? |
| employer_discussed_mh | Has your employer ever formally discussed mental health (for example, as part of a wellness campaign or other official communication)? |
| anonymity_protected | Is your anonymity protected if you choose to take advantage of mental health or substance abuse treatment resources provided by your employer? |
| consequence_mental | Do you think that discussing a mental health disorder with your employer would have negative consequences? |
| consequence_physical | Do you think that discussing a physical health issue with your employer would have negative consequences? |
| comfortable_coworkers | Would you feel comfortable discussing a mental health disorder with your coworkers? |
| comfortable_supervisor | Would you feel comfortable discussing a mental health disorder with your direct supervisor(s)? |
| employer_mh_serious | Do you feel that your employer takes mental health as seriously as physical health? |
| interview_physical | Would you be willing to bring up a physical health issue with a potential employer in an interview? |
| interview_mental | Would you bring up a mental health issue with a potential employer in an interview? |
| hurt_career | Do you feel that being identified as a person with a mental health issue would hurt your career? |
| coworkers_view_negative | Do you think that team members/co-workers would view you more negatively if they knew you suffered from a mental health issue? |
| mh_disorder_current | Do you currently have a mental health disorder? |
| sought_treatment | Have you ever sought treatment for a mental health issue from a mental health professional? |
| interfere_treated | If you have a mental health issue, do you feel that it interferes with your work when being treated effectively? |
| interfere_untreated | If you have a mental health issue, do you feel that it interferes with your work when NOT being treated effectively? |
| age | What is your age? |
| gender | What is your gender? |
| country_live | What country do you live in? |
| remote | Do you work remotely? |
employer_vars <- c("consequence_mental", "consequence_physical", "employer_discussed_mh", "mh_benefits")
miss_vars <- c(`Interview: mental` = "interview_mental",
`Interview: physical` = "interview_physical",
`Consequences: mental` = "consequence_mental",
`Consequences: physical` = "consequence_physical",
`Employer discussed mental health` = "employer_discussed_mh",
`Employer mental health benefits` = "mh_benefits",
`Interference when treated` = "interfere_treated",
`Interference when untreated` = "interfere_untreated",
`Current disorder` = "mh_disorder_current",
`Sought treatment` = "sought_treatment",
`Age` = "age",
`Gender` = "gender_group",
`Region` = "region_live")
miss <- imap_dfr(miss_vars, function(v, lab) {
na <- is.na(osmi[[v]])
design <- if (v %in% employer_vars) na & !osmi$is_employee else rep(FALSE, length(na))
tibble(variable = lab,
`Not asked (self-employed)` = mean(design),
`Not applicable, invalid or blank` = mean(na & !design))
}) %>%
mutate(total = `Not asked (self-employed)` + `Not applicable, invalid or blank`,
variable = fct_reorder(variable, total)) %>%
pivot_longer(c(`Not asked (self-employed)`, `Not applicable, invalid or blank`),
names_to = "reason", values_to = "pct")
ggplot(miss, aes(x = pct, y = variable, fill = reason)) +
geom_col(width = 0.65) +
geom_text(data = distinct(miss, variable, total),
aes(x = total, y = variable, label = pc(total)), inherit.aes = FALSE,
hjust = -0.2, size = 3.3, colour = "grey25") +
scale_fill_manual(values = c(`Not asked (self-employed)` = col_mid,
`Not applicable, invalid or blank` = col_neutral)) +
scale_x_continuous(labels = percent, limits = c(0, 0.5), expand = c(0, 0)) +
labs(title = "Most missing values are structural, not refusals",
subtitle = paste0("Share of the ", n_all, " respondents with no usable answer"),
x = NULL, y = NULL, fill = NULL)
Missing values in the analysis variables. Grey = question not asked (self-employed); blue = not applicable, invalid or blank.
Employer questions are analysed for the 1146 employees; interference questions for the 860 who answered both. Demographics are almost complete.
med_age <- median(osmi$age, na.rm = TRUE)
ggplot(filter(osmi, !is.na(age)), aes(age)) +
geom_histogram(binwidth = 2, boundary = 18, fill = col_neutral, colour = "white") +
geom_vline(xintercept = med_age, linetype = "dashed", colour = "grey30") +
annotate("text", x = med_age + 1, y = Inf, vjust = 1.5, hjust = 0,
label = paste("Median", med_age, "years"), colour = "grey30", size = 3.5) +
labs(title = "Most respondents are in their late 20s and 30s",
subtitle = "Number of respondents by age (2-year bins)",
x = "Age (years)", y = NULL,
caption = paste0(sum(osmi$age_flag), " impossible ages (3, 15, 17, 99, 323) were set to missing and are not shown.")) +
theme(panel.grid.major.y = element_line(colour = "grey92"), panel.grid.major.x = element_blank())
Age distribution after removing impossible values.
profile_vars <- c(`Age group` = "age_group", Gender = "gender_group",
`Region (lives in)` = "region_live", `Works remotely` = "remote",
`Current mental health disorder` = "mh_disorder_current",
`Self-employed` = "self_employed")
profile <- imap_dfr(profile_vars, function(var, label) {
osmi %>%
filter(!is.na(.data[[var]])) %>%
count(level = .data[[var]]) %>%
mutate(variable = label, pct = n / sum(n),
key = paste0(label, "__", level)) # unique key so each panel keeps its own order
}) %>%
mutate(variable = factor(variable, levels = names(profile_vars)),
key = fct_rev(fct_inorder(key)))
ggplot(profile, aes(x = pct, y = key)) +
geom_col(fill = col_neutral, width = 0.65) +
geom_text(aes(label = pc(pct)), hjust = -0.15, size = 3.2, colour = "grey25") +
facet_wrap(~ variable, scales = "free_y", ncol = 2) +
scale_y_discrete(labels = function(x) sub(".*__", "", x)) +
scale_x_continuous(expand = expansion(mult = c(0, 0.25))) +
labs(title = "A self-selected sample: mostly US-based men in tech",
subtitle = paste0("Profile of the ", n_all, " survey respondents"), x = NULL, y = NULL) +
theme(axis.text.x = element_blank(), panel.grid.major.x = element_blank(),
strip.text = element_text(face = "bold", hjust = 0))
Profile of the respondents (missing answers excluded).
world <- map_data("world") %>% filter(lat > -56)
# Centroid of each country's largest polygon (keeps the US bubble off Alaska)
centroids <- world %>%
add_count(region, group, name = "n_pts") %>%
group_by(region) %>%
filter(n_pts == max(n_pts)) %>%
summarise(long = mean(range(long)), lat = mean(range(lat)), .groups = "drop")
countries <- osmi %>%
filter(!is.na(region_live)) %>%
count(country_live, region_live) %>%
mutate(region = recode(country_live, "United States of America" = "USA",
"United Kingdom" = "UK")) %>%
left_join(centroids, by = "region")
stopifnot(!anyNA(countries$long)) # every survey country must be found on the map
small <- filter(countries, n < 5)
# Label positions (lx, ly) for the countries with 30+ respondents
map_labels <- tribble(
~region, ~lx, ~ly, ~hj, ~sx, ~sy, # (lx, ly) text position; (sx, sy) end of leader line
"USA", -100, 13, 0.5, NA, NA,
"Canada", -112, 73, 0.5, NA, NA,
"UK", -15, 79, 0.5, -10, 75,
"Netherlands", 35, 68, 0, 34, 68,
"Germany", 40, 38, 0, 39, 38,
"Australia", 97, -25, 1, 98, -25
) %>%
left_join(countries, by = "region") %>%
mutate(label = paste0(recode(country_live, "United States of America" = "United States"),
" (", n, ")"),
leader = !region %in% c("USA", "Canada")) # these two are labelled next to their bubble
stopifnot(!anyNA(map_labels$n))
ggplot() +
geom_polygon(data = world, aes(long, lat, group = group),
fill = "grey90", colour = "white", linewidth = 0.2) +
geom_point(data = arrange(countries, desc(n)),
aes(long, lat, size = n, fill = region_live),
shape = 21, colour = "white", stroke = 0.4, alpha = 0.85) +
# Labels for the countries with 30+ respondents, placed by hand with leader lines
geom_segment(data = filter(map_labels, leader), aes(x = long, y = lat, xend = sx, yend = sy),
colour = "grey50", linewidth = 0.3) +
geom_text(data = map_labels, aes(x = lx, y = ly, label = label, hjust = hj),
size = 3.1, colour = "grey20") +
scale_size_area(max_size = 18, breaks = c(1, 10, 100, 800), name = "Respondents") +
scale_fill_manual(values = c(`United States` = "#2a78d6", `United Kingdom` = "#E69F00",
`Rest of Europe` = "#009E73", `Rest of world` = "#CC79A7"),
name = "Region") +
guides(fill = guide_legend(override.aes = list(size = 5)),
size = guide_legend(order = 1, override.aes = list(fill = "grey55"))) +
coord_quickmap() +
labs(title = paste0("Respondents come from ", nrow(countries), " countries, but most live in the US"),
subtitle = "Number of respondents by country of residence; bubble area is proportional to the count",
caption = paste0(nrow(small), " countries have fewer than 5 respondents (",
sum(small$n), " people in total).")) +
theme_void(base_size = 12) +
theme(plot.title = element_text(face = "bold"),
plot.subtitle = element_text(colour = "grey35"),
plot.caption = element_text(colour = "grey45", hjust = 0),
plot.title.position = "plot", plot.caption.position = "plot",
legend.position = "right", plot.margin = margin(5.5, 20, 5.5, 5.5))
Respondents by country of residence. Bubble area is proportional to the number of respondents; colour shows the region used in the analysis.
Outside the US and UK, respondents are spread thinly, so countries are grouped into four regions.
The sample is not representative: it is self-selected, 74% men, 59% US-based, and 74% report a current, past or possible condition. 23% of employees do not work at a tech company.
# Map each question's answers to No / Maybe / Yes for a statement phrased so that Yes supports disclosure
stance <- function(x, yes, unsure, no) {
factor(case_when(x %in% yes ~ "Yes",
x %in% unsure ~ "Maybe / Don't know",
x %in% no ~ "No"),
levels = c("No", "Maybe / Don't know", "Yes"))
}
emp <- filter(osmi, is_employee)
landscape <- bind_rows(
osmi %>% transmute(q = "Would raise a mental health issue in a job interview",
answer = stance(interview_mental, "Yes", "Maybe", "No")),
osmi %>% transmute(q = "Being identified with a mental health issue would NOT hurt my career",
answer = stance(hurt_career, c("No, I don't think it would", "No, it has not"),
"Maybe", c("Yes, I think it would", "Yes, it has"))),
osmi %>% transmute(q = "Co-workers would NOT view me more negatively",
answer = stance(coworkers_view_negative, c("No, I don't think they would", "No, they do not"),
"Maybe", c("Yes, I think they would", "Yes, they do"))),
emp %>% transmute(q = "Comfortable discussing it with my supervisor (employees)",
answer = stance(comfortable_supervisor, "Yes", "Maybe", "No")),
emp %>% transmute(q = "Comfortable discussing it with co-workers (employees)",
answer = stance(comfortable_coworkers, "Yes", "Maybe", "No")),
emp %>% transmute(q = "Employer takes mental health as seriously as physical (employees)",
answer = stance(employer_mh_serious, "Yes", "I don't know", "No")),
emp %>% transmute(q = "Anonymity is protected if I use treatment resources (employees)",
answer = stance(anonymity_protected, "Yes", "I don't know", "No"))
) %>%
filter(!is.na(answer)) %>%
count(group = q, answer) %>%
group_by(group) %>%
mutate(pct = n / sum(n)) %>%
ungroup()
q_order <- landscape %>% filter(answer == "No") %>% arrange(pct) %>% pull(group)
landscape %>%
mutate(group = factor(str_wrap(group, 42), levels = str_wrap(q_order, 42))) %>%
stack_plot(pal_stance,
title = "The job interview is the biggest barrier to openness",
subtitle = "Share of answers to each statement (respondents who answered)",
caption = "Statements are phrased so that Yes always supports disclosure.")
Seven statements about disclosure, sorted by the share answering No. Each statement is phrased so that Yes supports disclosure.
People are most guarded with a potential employer, and many employees do not know what protection they have. The interview question has a physical-health twin, so we follow it up below, together with the treated/untreated interference pair.
interview_long <- osmi %>%
select(respondent_id, interview_physical, interview_mental) %>%
pivot_longer(-respondent_id, names_to = "health_type",
names_prefix = "interview_", values_to = "answer") %>%
mutate(health_type = factor(health_type, levels = c("mental", "physical"),
labels = c("Mental health issue", "Physical health issue")))
pct_stack(interview_long, health_type, answer, pal_raise,
title = "Few would raise a mental health issue in an interview",
subtitle = paste0("Would you bring it up with a potential employer? All respondents, n = ", n_all))
Willingness to raise a health issue with a potential employer in a job interview.
consequence_long <- emp %>%
select(respondent_id, consequence_physical, consequence_mental) %>%
pivot_longer(-respondent_id, names_to = "health_type",
names_prefix = "consequence_", values_to = "answer") %>%
mutate(health_type = factor(health_type, levels = c("mental", "physical"),
labels = c("Mental health disorder", "Physical health issue")),
# reworded so that Yes = no negative consequences (Yes always supports disclosure)
answer = factor(recode(as.character(answer), Yes = "No", No = "Yes"),
levels = c("No", "Maybe", "Yes")))
pct_stack(consequence_long, health_type, answer, pal_raise,
title = "Discussing mental health is seen as riskier",
subtitle = paste0("Would discussing it with your employer be free of negative consequences? Employees, n = ", n_emp),
caption = "Survey question reworded so that Yes always supports disclosure: red = No, blue = Yes in every chart.")
Expected consequences of discussing a health issue with the current employer.
tile <- osmi %>%
count(interview_physical, interview_mental) %>%
mutate(pct = n / sum(n),
direction = case_when(
answer_score(interview_physical) > answer_score(interview_mental) ~ "Less willing to raise mental\nthan physical health",
answer_score(interview_physical) < answer_score(interview_mental) ~ "More willing to raise mental\nthan physical health",
TRUE ~ "Same answer\nfor both"),
direction = factor(direction, levels = c("Less willing to raise mental\nthan physical health", "Same answer\nfor both",
"More willing to raise mental\nthan physical health")))
int_less <- mean(osmi$interview_gap > 0)
int_more <- mean(osmi$interview_gap < 0)
ggplot(tile, aes(x = interview_mental, y = interview_physical, fill = direction)) +
geom_tile(colour = "white", linewidth = 2) +
geom_text(aes(label = paste0(n, "\n(", pc(pct), ")")), size = 3.6, colour = "grey15") +
scale_fill_manual(values = c(`Less willing to raise mental\nthan physical health` = lighten(col_bad, 0.35),
`Same answer\nfor both` = "#e8e6e1",
`More willing to raise mental\nthan physical health` = lighten(col_good, 0.45))) +
labs(title = paste0(pc(int_less), " are less willing to raise mental than physical health"),
subtitle = paste0("Only ", pc(int_more), " are more willing. All respondents, n = ", n_all),
x = "Would raise a MENTAL health issue", y = "Would raise\na PHYSICAL\nhealth issue",
fill = NULL) +
theme(panel.grid = element_blank(), legend.position = "top")
Each cell counts people with a given pair of answers. Red cells: less willing to raise a mental issue than a physical one.
cons <- filter(emp, !is.na(consequence_gap))
bind_rows(
paired_row("Interview: would raise it (all respondents)",
sum(osmi$interview_gap > 0), sum(osmi$interview_gap == 0), sum(osmi$interview_gap < 0)),
paired_row("Employer: expects negative consequences (employees)",
sum(cons$consequence_gap > 0), sum(cons$consequence_gap == 0), sum(cons$consequence_gap < 0))
) %>%
mutate(`Wilcoxon p` = c(wilcox_p(osmi$interview_physical, osmi$interview_mental),
wilcox_p(cons$consequence_mental, cons$consequence_physical))) %>%
kable(caption = "Paired comparison of mental vs physical health. Sign test: direction of change only; Wilcoxon signed-rank test: also the size of the change.")
| Comparison | n | Worse for mental health / when untreated | 95% CI | Same answer | Other direction | Sign test p | Wilcoxon p |
|---|---|---|---|---|---|---|---|
| Interview: would raise it (all respondents) | 1433 | 42% | 40% to 45% | 55% | 2% | < 0.001 | < 0.001 |
| Employer: expects negative consequences (employees) | 1146 | 44% | 41% to 47% | 55% | 1% | < 0.001 | < 0.001 |
phys_yes <- filter(osmi, interview_physical == "Yes")
Interpretation. 42% are less willing to raise mental than physical health in an interview (2% more willing); of those who would raise a physical issue, 73% would not or are unsure about a mental one. 44% of employees expect worse consequences for mental health.
both <- filter(osmi, interfere_both)
interference_long <- both %>%
select(respondent_id, interfere_treated, interfere_untreated) %>%
pivot_longer(-respondent_id, names_to = "treatment",
names_prefix = "interfere_", values_to = "answer") %>%
mutate(treatment = factor(treatment, levels = c("untreated", "treated"),
labels = c("Not treated effectively", "Treated effectively")))
pct_stack(interference_long, treatment, answer, pal_freq,
title = "Untreated, mental health interferes with work far more often",
subtitle = paste0("How often does a mental health issue interfere with your work? n = ", n_both))
How often a mental health issue interferes with work, among respondents who answered both questions.
spread_labels <- function(y, gap = 0.045) { # nudge labels apart so they never overlap
o <- order(y); ys <- y[o]
for (i in seq_along(ys)[-1]) if (ys[i] - ys[i - 1] < gap) ys[i] <- ys[i - 1] + gap
y[o] <- ys; y
}
slope <- both %>%
group_by(group = mh_disorder_current) %>%
summarise(n = n(),
Treated = mean(interfere_treated == "Often"),
Untreated = mean(interfere_untreated == "Often"), .groups = "drop") %>%
mutate(label = paste0(recode(group, Yes = "Current disorder", Maybe = "Maybe a disorder",
No = "No current disorder"), " (n = ", n, ")"),
highlight = group == "Yes") %>%
pivot_longer(c(Treated, Untreated), names_to = "when", values_to = "pct") %>%
group_by(when) %>% mutate(label_y = spread_labels(pct)) %>% ungroup()
ggplot(slope, aes(x = when, y = pct, group = group, colour = highlight)) +
geom_line(aes(linewidth = highlight)) +
geom_point(size = 3) +
geom_text(data = filter(slope, when == "Treated"),
aes(y = label_y, label = pc(pct)), hjust = 1.4, size = 3.6) +
geom_text(data = filter(slope, when == "Untreated"),
aes(y = label_y, label = paste0(pc(pct), " ", label)), hjust = -0.12, size = 3.6) +
scale_colour_manual(values = c(`TRUE` = col_accent, `FALSE` = col_muted), guide = "none") +
scale_linewidth_manual(values = c(`TRUE` = 1.6, `FALSE` = 0.8), guide = "none") +
scale_x_discrete(labels = c("Treated\neffectively", "Not treated\neffectively"),
expand = expansion(add = c(0.35, 1.7))) +
scale_y_continuous(NULL, breaks = NULL) +
labs(title = "Without treatment, 'often' rises sevenfold for people with a disorder",
subtitle = "Share saying a mental health issue interferes with their work 'often'", x = NULL) +
theme(panel.grid = element_blank())
Share answering ‘often’, treated vs not treated effectively, by current mental health disorder. Dark blue = people with a current disorder.
cur <- filter(both, mh_disorder_current == "Yes")
bind_rows(
paired_row("All who answered both questions",
sum(both$interfere_shift > 0), sum(both$interfere_shift == 0), sum(both$interfere_shift < 0)),
paired_row("Sensitivity: current disorder only",
sum(cur$interfere_shift > 0), sum(cur$interfere_shift == 0), sum(cur$interfere_shift < 0))
) %>%
mutate(`Wilcoxon p` = c(wilcox_p(both$interfere_untreated, both$interfere_treated),
wilcox_p(cur$interfere_untreated, cur$interfere_treated))) %>%
kable(caption = "Paired comparison of interference when untreated vs treated. 'Worse' = more frequent interference when untreated. Sign test: direction of change only; Wilcoxon signed-rank test: also the size of the change.")
| Comparison | n | Worse for mental health / when untreated | 95% CI | Same answer | Other direction | Sign test p | Wilcoxon p |
|---|---|---|---|---|---|---|---|
| All who answered both questions | 860 | 75% | 72% to 78% | 23% | 2% | < 0.001 | < 0.001 |
| Sensitivity: current disorder only | 534 | 80% | 76% to 83% | 19% | 1% | < 0.001 | < 0.001 |
Interpretation. “Often interferes” rises from 7% when treated to 58% when not (“sometimes or often”: 49% vs 94%); 75% report more interference when untreated. Among people with a current disorder the rise is 10% → 69%.
ci_by <- function(data, var, label) {
data %>%
filter(!is.na(.data[[var]])) %>%
group_by(level = .data[[var]]) %>%
summarise(n = n(), x = sum(interview_gap > 0), .groups = "drop") %>%
mutate(pct = x / n,
lo = map2_dbl(x, n, ~ prop.test(.x, .y)$conf.int[1]),
hi = map2_dbl(x, n, ~ prop.test(.x, .y)$conf.int[2]),
level = as.character(level), factor = label)
}
groups <- bind_rows(
ci_by(osmi, "mh_disorder_current", "Current disorder"),
ci_by(osmi, "sought_treatment", "Sought treatment"),
ci_by(osmi, "gender_group", "Gender"),
ci_by(osmi, "region_live", "Region"),
ci_by(osmi, "age_group", "Age")
) %>%
mutate(factor = fct_inorder(factor),
key = fct_rev(fct_inorder(paste0(factor, "__", level, " (n = ", n, ")"))),
highlight = factor == "Current disorder")
ggplot(groups, aes(x = pct, y = key, colour = highlight)) +
geom_vline(xintercept = int_less, linetype = "dashed", colour = "grey55") +
geom_errorbarh(aes(xmin = lo, xmax = hi), height = 0, linewidth = 0.8) +
geom_point(size = 3) +
geom_text(aes(x = hi, label = pc(pct)), hjust = -0.3, size = 3.2, show.legend = FALSE) +
facet_grid(factor ~ ., scales = "free_y", space = "free_y") +
scale_colour_manual(values = c(`TRUE` = col_accent, `FALSE` = "grey45"), guide = "none") +
scale_y_discrete(labels = function(x) sub(".*__", "", x)) +
scale_x_continuous(labels = percent, limits = c(0, 0.8), expand = c(0, 0)) +
labs(title = "The gap is widest for people who live with a mental health condition",
subtitle = "Share less willing to raise mental than physical health in an interview",
x = NULL, y = NULL) +
theme(strip.text.y = element_text(angle = 0, hjust = 0, face = "bold"),
panel.grid.major.x = element_line(colour = "grey92"),
panel.spacing = unit(0.8, "lines"))
Share less willing to raise mental than physical health in an interview, with 95% confidence intervals. Dashed line = all respondents.
gap_cur <- groups %>% filter(factor == "Current disorder") %>% select(level, pct) %>% tibble::deframe()
chi_p <- chisq.test(table(osmi$mh_disorder_current, osmi$interview_gap > 0))$p.value
mental_no <- function(var, lvl) mean(osmi$interview_mental[osmi[[var]] %in% lvl] == "No")
The gap is largest for people with a current disorder (46% vs 36%, chi-squared p 0.0012). Gender hardly changes the gap, but women are more guarded overall (75% vs 57% would not raise a mental health issue), as is the US compared with the rest of Europe (67% vs 48%).
# 1. Employees whose employer has / has not formally discussed mental health
employer_action <- emp %>%
filter(employer_discussed_mh %in% c("Yes", "No")) %>%
mutate(action = factor(if_else(employer_discussed_mh == "Yes",
"Has discussed mental health", "Has not discussed it"),
levels = c("Has not discussed it", "Has discussed mental health")),
comfortable = comfortable_supervisor == "Yes",
no_fear = consequence_mental == "No")
# 2. Share answering positively for each outcome and group, plus the difference
outcome_labels <- c(comfortable = "Comfortable discussing it\nwith supervisor",
no_fear = "Expects no negative\nconsequences")
emp_res <- employer_action %>%
pivot_longer(c(comfortable, no_fear), names_to = "outcome", values_to = "positive") %>%
filter(!is.na(positive)) %>%
group_by(outcome, action) %>%
summarise(pct = mean(positive), .groups = "drop")
emp_diff <- emp_res %>%
group_by(outcome) %>%
summarise(diff = pct[action == "Has discussed mental health"] - pct[action == "Has not discussed it"],
top = max(pct))
# 3. Plot: grouped columns, grey vs accent, values and difference written on the chart
ggplot(emp_res, aes(x = outcome, y = pct, fill = action)) +
geom_col(position = position_dodge(0.75), width = 0.7) +
geom_text(aes(label = pc(pct)), position = position_dodge(0.75),
vjust = -0.5, size = 4, colour = "grey20") +
geom_text(data = emp_diff, aes(x = outcome, y = top + 0.13,
label = paste0("+", round(diff * 100), " pts")),
inherit.aes = FALSE, size = 4.2, fontface = "bold", colour = col_accent) +
scale_x_discrete(labels = outcome_labels) +
scale_y_continuous(limits = c(0, 0.8), expand = c(0, 0)) +
scale_fill_manual(values = c(`Has not discussed it` = "grey70",
`Has discussed mental health` = col_accent),
labels = c("Employer has not discussed mental health",
"Employer has discussed mental health")) +
labs(title = "Where employers talk about mental health, employees feel safer",
x = NULL, y = NULL, fill = NULL,
caption = "Employees only. Associations, not causal effects.") +
theme(axis.text.y = element_blank(), panel.grid = element_blank(),
axis.text.x = element_text(size = 11, colour = "grey20"),
legend.direction = "vertical")
Share of employees who feel comfortable and who expect no negative consequences, by whether their employer has formally discussed mental health.
# 4. Numbers used in the text
emp_share <- function(outcome_name, group) emp_res$pct[emp_res$outcome == outcome_name & emp_res$action == group]
benefit <- function(var, lvl) mean(emp[[var]][emp$mh_benefits == lvl] == c(comfortable_supervisor = "Yes", consequence_mental = "No")[var], na.rm = TRUE)
gap_emp <- function(var, lvl) mean(emp$interview_gap[emp[[var]] %in% lvl] > 0)
chi_disc <- chisq.test(table(employer_action$action, employer_action$comfortable))$p.value
Where the employer has discussed mental health, 58% are comfortable talking to their supervisor (vs 31%, chi-squared p < 0.001) and 55% expect no negative consequences (vs 32%). Offering benefits shows a smaller difference (43% vs 29% comfortable; 39% vs 31% expect no consequences), and the interview gap does not change (44% vs 42%).
# 1. Label each employee: did the previous and the current employer discuss mental health?
trajectory <- emp %>%
filter(prev_discussed_mh %in% c("None did", "Some did", "Yes, they all did"),
employer_discussed_mh %in% c("Yes", "No")) %>%
mutate(previous = if_else(prev_discussed_mh == "None did", "Closed", "Open"),
current = if_else(employer_discussed_mh == "Yes", "open", "closed"),
traj = paste(previous, "→", current),
comfortable = comfortable_supervisor == "Yes",
no_fear = consequence_mental == "No")
# 2. Share answering positively, with 95% CI, for each group and outcome
traj_res <- trajectory %>%
pivot_longer(c(comfortable, no_fear), names_to = "outcome", values_to = "positive") %>%
filter(!is.na(positive)) %>%
group_by(outcome, traj) %>%
summarise(n = n(), x = sum(positive), .groups = "drop") %>%
mutate(pct = x / n,
lo = map2_dbl(x, n, ~ prop.test(.x, .y)$conf.int[1]),
hi = map2_dbl(x, n, ~ prop.test(.x, .y)$conf.int[2]))
# 3. Plot: one panel per outcome (labels defined in 7.2), "Closed → open" highlighted
ggplot(traj_res, aes(x = pct, y = fct_rev(traj), colour = traj == "Closed → open")) +
geom_errorbarh(aes(xmin = lo, xmax = hi), height = 0, linewidth = 0.8) +
geom_point(size = 3) +
geom_text(aes(x = hi, label = pc(pct)), hjust = -0.3, size = 3.2) +
facet_wrap(~ outcome, nrow = 1, labeller = as_labeller(outcome_labels)) +
scale_colour_manual(values = c(`TRUE` = col_accent, `FALSE` = "grey45"), guide = "none") +
scale_x_continuous(labels = percent, limits = c(0, 0.9), expand = c(0, 0)) +
labs(title = "Moving to an open employer goes with more comfort and less fear",
subtitle = "Previous employer → current employer, by whether mental health was formally discussed",
x = NULL, y = NULL,
caption = "Previous 'open' = some or all previous employers discussed mental health. Employees with a previous employer; recalled, not tracked over time.") +
theme(strip.text = element_text(face = "bold", hjust = 0),
panel.grid.major.x = element_line(colour = "grey92"),
panel.spacing = unit(1.5, "lines"))
Comfort and expected consequences at the current employer, by whether previous and current employers formally discussed mental health (employees with a previous employer), with 95% confidence intervals.
# 4. Numbers used in the text
share <- function(outcome_name, group) traj_res$pct[traj_res$outcome == outcome_name & traj_res$traj == group]
# Previously closed: did moving to an open employer change comfort? (stayed vs moved)
from_closed <- filter(trajectory, previous == "Closed", !is.na(comfortable))
chi_traj <- chisq.test(table(from_closed$current, from_closed$comfortable))$p.value
# Same people: expected negative consequences at ALL previous employers; share still expecting them now
feared_before <- filter(trajectory, previous == "Closed",
prev_consequence_mental == "Yes, all of them", !is.na(consequence_mental))
still_fear <- tapply(feared_before$consequence_mental == "Yes", feared_before$current, mean)
Comfort follows the current employer: after moving from a closed to an open employer, 59% are comfortable talking to their supervisor, against 31% who stayed in closed workplaces (chi-squared p < 0.001); expecting no consequences rises from 34% to 53%. Of those who expected negative consequences at all previous employers, 24% still do after moving to an open employer, against 53% who stayed in closed ones.
Sample: 1433 tech workers, mostly US-based men, median age 33; self-selected.
Key findings
Key message: The tech workers who most need support are the least willing to speak up; untreated, their mental health disrupts their work, and fear is lower where employers talk openly about it.
Limitations