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)

1 Introduction

Data: OSMI Mental Health in Tech Survey 2016 (Kaggle, CC BY-SA 4.0), 1433 respondents working in tech.

Questions:

  1. Are people less willing to disclose a mental than a physical health issue at work?
  2. How much more does mental health interfere with work when it is not treated effectively?
  3. Do employees feel safer disclosing where their employer openly discusses mental health, including after moving from a closed to an open employer?

2 Data and cleaning

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
Variables used in this analysis (click to expand)
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?

3 Data quality: missing values, outliers and bias

3.1 Missing values

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)
Stacked horizontal bars showing the share of missing values for 13 analysis variables. Employer questions are about 20% missing because self-employed respondents were not asked; the two work-interference questions are 33 to 39% missing because of 'Not applicable' answers; all other variables are almost complete.

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.

3.2 Outliers: age

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())
Histogram of respondent age from 19 to 74, peaking around 30 to 35, median 33 years.

Age distribution after removing impossible values.

3.3 Bias and balance: who answered?

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))
Six small bar charts: 47% aged 30 to 39; 74% men, 24% women; 59% live in the US; 53% sometimes work remotely; 40% report a current mental health disorder and 23% maybe; 20% are self-employed.

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))
World map with bubbles sized by number of respondents. A very large bubble over the United States (840), a large one over the United Kingdom (180), medium bubbles for Canada, Germany, the Netherlands and Australia, and many small bubbles across Europe, South America, Asia and Africa.

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.

4 First look: attitudes to disclosure

# 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.")
Stacked bars for seven questions. The largest barrier is the job interview: 62% would not raise a mental health issue. 47% think being identified would hurt their career. For anonymity, 65% do not know whether it is protected.

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.

5 Message 1: the disclosure gap

5.1 Mental vs physical health

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))
Two stacked bars. Mental health issue: 62% no, 31% maybe, 8% yes. Physical health issue: 31% no, 44% maybe, 25% yes.

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.")
Two stacked bars for employees: would discussing it be free of negative consequences? Mental health disorder: 19% no, 42% maybe, 38% yes. Physical health issue: 4% no, 23% maybe, 73% yes.

Expected consequences of discussing a health issue with the current employer.

5.2 Same person, two answers

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")
Three by three grid of answer pairs. The diagonal (same answer) holds 795 people. Above the diagonal, 604 people (42%) are less willing for mental health; below it only 34 (2%) are more willing.

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.")
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.

6 Message 2: the cost of untreated mental health

6.1 Treated vs not treated effectively

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))
Two stacked bars. Treated effectively: 14% never, 37% rarely, 42% sometimes, 7% often. Not treated effectively: 1% never, 5% rarely, 35% sometimes, 58% often.

How often a mental health issue interferes with work, among respondents who answered both questions.

6.2 Who is affected most?

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())
Slopegraph with three lines. Current disorder: 10% often when treated, 69% when not. Maybe: 5% to 41%. No current disorder: 1% to 41%.

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.")
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%.

7 Who stays silent, and does the employer matter?

7.1 The gap across groups

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"))
Dot plot with confidence intervals for 16 groups. People with a current disorder (46%) and those who sought treatment (47%) show the largest gap; people without a disorder show the smallest (36%). Gender differences are small; the UK (47%) and ages 18 to 29 (47%) are above average.

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%).

7.2 Employer support

# 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")
Grouped column chart. Comfortable discussing it with supervisor: 31% where the employer has not discussed mental health vs 58% where it has (+27 points). Expects no negative consequences: 32% vs 55% (+22 points).

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%).

7.2.1 Previous vs current employer

# 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"))
Two dot plots with confidence intervals for four groups. Comfortable with supervisor: closed to closed 31%, closed to open 59%, open to closed 34%, open to open 62%. Expects no negative consequences: 34%, 53%, 31% and 62%.

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.

8 Conclusion and key message

Sample: 1433 tech workers, mostly US-based men, median age 33; self-selected.

Key findings

  1. 42% are less willing to raise mental than physical health with a potential employer (2% more willing).
  2. The gap is largest for people with a current disorder (46% vs 36%).
  3. Without effective treatment, “often interferes with work” rises from 7% to 58% (10% to 69% with a current disorder).
  4. Where employers have discussed mental health, 55% expect no negative consequences (vs 32%); after moving from a closed to an open employer, comfort with the supervisor is 59% vs 31% for those who stayed in closed workplaces.

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

  • Self-selected online sample; 23% of employees work outside tech companies.
  • Mostly US-based and male.
  • 2016, self-reported, cross-sectional: associations only.
  • The “not treated effectively” answer is hypothetical for some respondents.
  • Previous-employer answers are recalled, not tracked over time.