library(dplyr)
library(tidyr)
library(forcats)
library(ggplot2)
library(scales)
library(knitr)
# Cleaned data from clean_osmi_2016.R
osmi <- readRDS("osmi_clean.rds")
codebook <- readr::read_csv("osmi_codebook.csv", show_col_types = FALSE)
# One quiet theme for every chart: recessive grid, no chart junk
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.subtitle = element_text(colour = "grey35"),
plot.caption = element_text(colour = "grey45", hjust = 0),
legend.position = "top",
legend.justification = "left")
theme_set(theme_eda)
# Diverging answer scale: red <-> grey midpoint <-> blue (colourblind-safe poles)
pal_raise <- c(No = "#e34948", Maybe = "#c9c7c1", Yes = "#2a78d6") # "would raise it": Yes = good
pal_expect <- c(No = "#2a78d6", Maybe = "#c9c7c1", Yes = "#e34948") # "expect negative consequences": Yes = bad
# Ordered frequency scale: one hue, light -> dark
pal_freq <- c(Never = "#86b6ef", Rarely = "#3987e5", Sometimes = "#1c5cab", Often = "#0d366b")
# Helper: horizontal 100% stacked bars, labelling segments of 5% or more.
# Labels on light segments (grey midpoint, lightest blue) are dark for readability.
light_levels <- c("Maybe", "Never")
pct_stack <- function(data, group, answer, palette, title, subtitle = NULL, caption = NULL) {
data %>%
filter(!is.na({{ answer }})) %>%
count({{ group }}, {{ answer }}) %>%
group_by({{ group }}) %>%
mutate(pct = n / sum(n)) %>%
ungroup() %>%
ggplot(aes(x = pct, y = {{ group }}, fill = {{ answer }})) +
geom_col(width = 0.6, colour = "white", linewidth = 0.6,
position = position_stack(reverse = TRUE)) +
geom_text(aes(label = if_else(pct >= 0.05, percent(pct, accuracy = 1), ""),
colour = {{ answer }} %in% light_levels, group = {{ answer }}),
position = position_stack(vjust = 0.5, reverse = TRUE),
size = 3.6, fontface = "bold", show.legend = FALSE) +
scale_colour_manual(values = c(`TRUE` = "grey15", `FALSE` = "white"), guide = "none") +
scale_x_continuous(labels = percent, expand = c(0, 0)) +
scale_fill_manual(values = palette, drop = FALSE) +
labs(title = title, subtitle = subtitle, caption = caption,
x = "Share of respondents", y = NULL, fill = NULL) +
theme(plot.margin = margin(5.5, 20, 5.5, 5.5))
}
Data source. The Open Sourcing Mental Illness (OSMI)
Mental Health in Tech Survey 2016, an open, anonymous online
survey of people working in the tech industry, published by OSMI and
distributed on Kaggle (file
mental-heath-in-tech-2016_20161114.csv). It contains 1433
respondents and 63 questions on workplace mental health support,
attitudes to disclosure and respondents’ own mental health.
Aim of this EDA. To explore two related questions and decide on the key message for our explanatory visualisation:
Intended audience. Employers, HR leaders and people managers in the tech sector, who control workplace policy and mental health benefits.
Cleaning was done in clean_osmi_2016.R. The key
decisions:
| Issue | Decision |
|---|---|
| Full-question column headers (two named “Why or why not?”) | Renamed to short variable names; codebook kept (below) |
| Employer questions blank for self-employed respondents (not asked) | Employer “negative consequences” pair 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 | Recoded to missing; analysis restricted to respondents answering both |
| “Maybe” answers | Kept as a separate middle category |
| Impossible ages (3, 15, 17, 99, 323) | Set to missing; respondents kept |
| Gender as free text (72 spellings) | Grouped into Man / Woman / Non-binary or other by stated identity; unclear answers set to missing |
| 53 countries, majority US | Grouped into US / UK / Rest of Europe / Rest of world |
tibble(
`Analysis population` = c(
"All respondents",
"Employees (employer consequence 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(
nrow(osmi),
sum(osmi$is_employee, na.rm = TRUE),
sum(osmi$interfere_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 consequence 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 |
Variables used in this analysis
codebook %>%
filter(variable %in% c("self_employed", "mh_benefits", "employer_discussed_mh",
"consequence_mental", "consequence_physical",
"interview_physical", "interview_mental",
"mh_disorder_past", "mh_disorder_current",
"diagnosed_professional",
"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? |
| 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)? |
| 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? |
| 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? |
| mh_disorder_past | Have you had a mental health disorder in the past? |
| mh_disorder_current | Do you currently have a mental health disorder? |
| diagnosed_professional | Have you been diagnosed with a mental health condition by a medical 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? |
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")
profile <- purrr::imap_dfr(profile_vars, function(var, label) {
osmi %>%
mutate(level = fct_na_value_to_level(.data[[var]], level = "Missing")) %>%
count(level) %>%
mutate(variable = label, pct = n / sum(n),
# unique key per facet so each facet keeps its own level order
key = paste0(label, "__", level))
}) %>%
mutate(variable = factor(variable, levels = names(profile_vars)),
key = fct_rev(fct_inorder(key)))
ggplot(profile, aes(x = pct, y = key)) +
geom_col(fill = "#2a78d6", width = 0.6) +
geom_text(aes(label = percent(pct, accuracy = 1)), 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(labels = percent, expand = expansion(mult = c(0, 0.2))) +
labs(title = "A US-based, male-majority, self-selected sample",
subtitle = paste0("Profile of the ", nrow(osmi), " survey respondents"),
x = "Share of respondents", y = NULL)
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 = "Would you bring it up with a potential employer in an interview?",
subtitle = paste0("All respondents, n = ", nrow(osmi)))
consequence_long <- osmi %>%
filter(is_employee) %>%
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")))
pct_stack(consequence_long, health_type, answer, pal_expect,
title = "Would discussing it with your employer have negative consequences?",
subtitle = paste0("Employees only, n = ", sum(osmi$is_employee, na.rm = TRUE)),
caption = "Colours reversed versus the interview chart so that red always marks the less favourable answer.")
Each person answered both the physical and the mental question, so we can compare their two answers directly rather than only comparing totals.
answer_score <- function(x) as.integer(x) - 1 # No = 0, Maybe = 1, Yes = 2
osmi <- osmi %>%
mutate(
interview_gap = answer_score(interview_physical) - answer_score(interview_mental),
interview_gap_cat = factor(sign(interview_gap), levels = c(1, 0, -1),
labels = c("Less willing for mental", "Equally willing",
"More willing for mental")),
consequence_gap = answer_score(consequence_mental) - answer_score(consequence_physical),
consequence_gap_cat = factor(sign(consequence_gap), levels = c(1, 0, -1),
labels = c("Expects worse for mental", "Same expectation",
"Expects worse for physical"))
)
# Cross-tab: physical answer (rows) x mental answer (columns)
osmi %>%
count(Physical = interview_physical, Mental = interview_mental) %>%
pivot_wider(names_from = Mental, values_from = n, values_fill = 0) %>%
kable(caption = "Interview: physical answer (rows) by mental answer (columns)")
| Physical | No | Maybe | Yes |
|---|---|---|---|
| No | 420 | 18 | 3 |
| Maybe | 341 | 279 | 13 |
| Yes | 122 | 141 | 96 |
osmi %>%
count(interview_physical, interview_mental) %>%
mutate(pct = n / sum(n)) %>%
ggplot(aes(x = interview_mental, y = interview_physical, fill = pct)) +
geom_tile(colour = "white", linewidth = 1.5) +
geom_text(aes(label = paste0(n, "\n(", percent(pct, accuracy = 1), ")"),
colour = pct > (min(pct) + max(pct)) / 2), size = 3.4, show.legend = FALSE) +
scale_colour_manual(values = c(`TRUE` = "white", `FALSE` = "grey15")) +
scale_fill_gradient(low = "#cde2fb", high = "#0d366b", labels = percent) +
labs(title = "Same person, two answers",
subtitle = "Top-left cells: less willing to raise mental than physical",
x = "Would raise a MENTAL health issue", y = "Would raise a PHYSICAL health issue",
fill = "Share") +
theme(panel.grid = element_blank(), legend.position = "right")
bind_rows(
osmi %>% count(category = interview_gap_cat) %>% mutate(question = "Interview"),
osmi %>% filter(is_employee) %>% count(category = consequence_gap_cat) %>%
mutate(question = "Consequences (employees)")
) %>%
filter(!is.na(category)) %>%
group_by(question) %>%
mutate(pct = percent(n / sum(n), accuracy = 0.1)) %>%
select(question, category, n, pct) %>%
kable()
| question | category | n | pct |
|---|---|---|---|
| Interview | Less willing for mental | 604 | 42.1% |
| Interview | Equally willing | 795 | 55.5% |
| Interview | More willing for mental | 34 | 2.4% |
| Consequences (employees) | Expects worse for mental | 503 | 43.9% |
| Consequences (employees) | Same expectation | 631 | 55.1% |
| Consequences (employees) | Expects worse for physical | 12 | 1.0% |
Paired test. Wilcoxon signed-rank test on the ordered answers (No < Maybe < Yes):
wilcox.test(answer_score(osmi$interview_physical), answer_score(osmi$interview_mental),
paired = TRUE, exact = FALSE)
##
## Wilcoxon signed rank test with continuity correction
##
## data: answer_score(osmi$interview_physical) and answer_score(osmi$interview_mental)
## V = 194146, p-value < 2.2e-16
## alternative hypothesis: true location shift is not equal to 0
emp <- filter(osmi, is_employee)
wilcox.test(answer_score(emp$consequence_mental), answer_score(emp$consequence_physical),
paired = TRUE, exact = FALSE)
##
## Wilcoxon signed rank test with continuity correction
##
## data: answer_score(emp$consequence_mental) and answer_score(emp$consequence_physical)
## V = 130302, p-value < 2.2e-16
## alternative hypothesis: true location shift is not equal to 0
Interpretation: 42% of respondents were less willing to raise a mental health issue than a physical one in a job interview, compared with 2% who were more willing. Of those who would raise a physical issue, 73% would not, or were unsure whether they would, raise a mental one.
interference_long <- osmi %>%
filter(interfere_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 = "How often does a mental health issue interfere with your work?",
subtitle = paste0("Respondents who answered both questions, n = ",
sum(osmi$interfere_both)))
osmi <- osmi %>%
mutate(interfere_shift = as.integer(interfere_untreated) - as.integer(interfere_treated),
interfere_shift_cat = factor(sign(interfere_shift), levels = c(1, 0, -1),
labels = c("More interference when untreated",
"No difference",
"Less interference when untreated")))
osmi %>%
filter(interfere_both) %>%
count(Treated = interfere_treated, Untreated = interfere_untreated) %>%
pivot_wider(names_from = Untreated, values_from = n, values_fill = 0) %>%
kable(caption = "Treated answer (rows) by untreated answer (columns)")
| Treated | Never | Rarely | Sometimes | Often |
|---|---|---|---|---|
| Never | 9 | 18 | 54 | 38 |
| Rarely | 1 | 20 | 127 | 169 |
| Sometimes | 1 | 3 | 116 | 240 |
| Often | 0 | 3 | 5 | 56 |
osmi %>%
filter(interfere_both) %>%
count(interfere_shift_cat) %>%
mutate(pct = percent(n / sum(n), accuracy = 0.1)) %>%
kable(col.names = c("Change", "n", "%"))
| Change | n | % |
|---|---|---|
| More interference when untreated | 646 | 75.1% |
| No difference | 201 | 23.4% |
| Less interference when untreated | 13 | 1.5% |
wilcox.test(as.integer(osmi$interfere_untreated), as.integer(osmi$interfere_treated),
paired = TRUE, exact = FALSE)
##
## Wilcoxon signed rank test with continuity correction
##
## data: as.integer(osmi$interfere_untreated) and as.integer(osmi$interfere_treated)
## V = 213661, p-value < 2.2e-16
## alternative hypothesis: true location shift is not equal to 0
Some respondents with no reported condition answered the interference questions (likely hypothetically). Does the pattern hold when we keep only those with a current, past or diagnosed condition?
sens <- osmi %>% filter(interfere_both, has_condition)
sens %>%
count(interfere_shift_cat) %>%
mutate(pct = percent(n / sum(n), accuracy = 0.1)) %>%
kable(col.names = c("Change", "n", "%"),
caption = paste0("Respondents with a current, past or diagnosed condition, n = ", nrow(sens)))
| Change | n | % |
|---|---|---|
| More interference when untreated | 626 | 75.2% |
| No difference | 194 | 23.3% |
| Less interference when untreated | 12 | 1.4% |
wilcox.test(as.integer(sens$interfere_untreated), as.integer(sens$interfere_treated),
paired = TRUE, exact = FALSE)
##
## Wilcoxon signed rank test with continuity correction
##
## data: as.integer(sens$interfere_untreated) and as.integer(sens$interfere_treated)
## V = 200337, p-value < 2.2e-16
## alternative hypothesis: true location shift is not equal to 0
Interpretation (fill in): 58% report interfernce with work when mental health isses are untreated, while 7% report interference when mental health condition is treated.
Is the disclosure gap larger for people who have a condition, and smaller where employers support mental health?
gap_by <- function(data, var, label) {
data %>%
filter(!is.na(.data[[var]]), !is.na(interview_gap_cat)) %>%
group_by(level = as.character(.data[[var]])) %>%
summarise(n = n(),
pct_less_willing = mean(interview_gap_cat == "Less willing for mental"),
.groups = "drop") %>%
mutate(factor = label)
}
linking <- bind_rows(
gap_by(osmi %>% mutate(has_condition = if_else(has_condition, "Has / had a condition",
"No condition reported")),
"has_condition", "Own mental health"),
gap_by(filter(osmi, is_employee), "employer_discussed_mh",
"Employer has formally discussed mental health"),
gap_by(filter(osmi, is_employee), "mh_benefits",
"Employer provides mental health benefits")
)
kable(linking %>% mutate(pct_less_willing = percent(pct_less_willing, accuracy = 0.1)) %>%
select(factor, level, n, pct_less_willing),
col.names = c("Factor", "Group", "n", "% less willing to raise mental than physical"))
| Factor | Group | n | % less willing to raise mental than physical |
|---|---|---|---|
| Own mental health | Has / had a condition | 1064 | 45.5% |
| Own mental health | No condition reported | 369 | 32.5% |
| Employer has formally discussed mental health | I don’t know | 103 | 35.9% |
| Employer has formally discussed mental health | No | 813 | 41.6% |
| Employer has formally discussed mental health | Yes | 230 | 44.3% |
| Employer provides mental health benefits | I don’t know | 319 | 39.5% |
| Employer provides mental health benefits | No | 213 | 38.5% |
| Employer provides mental health benefits | Not eligible for coverage / N/A | 83 | 44.6% |
| Employer provides mental health benefits | Yes | 531 | 43.7% |
linking %>%
mutate(label = paste0(level, " (n = ", n, ")")) %>%
ggplot(aes(x = pct_less_willing, y = fct_reorder(label, pct_less_willing))) +
geom_segment(aes(x = 0, xend = pct_less_willing, yend = fct_reorder(label, pct_less_willing)),
colour = "grey80", linewidth = 0.8) +
geom_point(colour = "#2a78d6", size = 3.5) +
geom_text(aes(label = percent(pct_less_willing, accuracy = 1)), hjust = -0.5,
size = 3.3, colour = "grey25") +
facet_wrap(~ factor, ncol = 1, scales = "free_y") +
scale_x_continuous(labels = percent, limits = c(0, 1),
expand = expansion(mult = c(0, 0.03))) +
labs(title = "Where is the disclosure gap widest?",
subtitle = "Share less willing to raise mental than physical health in an interview",
x = NULL, y = NULL) +
theme(panel.grid.major.y = element_blank())
These are associations in a cross-sectional survey, not evidence that employer actions cause smaller gaps.
The respondents are majorly US-based and male, with the rest of the respondents scattered between the United Kingdom and the rest of Europe; 47% of respondents are aged between 30-39.
Key finding 1 (disclosure gap): 42% of tech workers are less willing to raise a mental health issue than a physical one with a potential employer (vs 2% more willing), and the gap is widest among those who have or had a condition (45% vs 33%).
Key finding 2 (cost of non-treatment): For those people, untreated mental health is what disrupts their work: “often” rises from 7% to 58% without effective treatment.
Key message for the explanatory visualisation: The people with mental health conditions are the most likely to stay silent, and for those same people, untreated mental health is what disrupts their work. Making it safe to disclose is a precondition for getting people effective treatment.
Target audience: employers, HR leaders and people managers in tech.
Limitations