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))
}

1 Introduction

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:

  1. Disclosure gap: are tech workers less willing to disclose a mental health issue at work than a physical one?
  2. Cost of non-treatment: how much more do people say a mental health issue interferes with their work when it is not treated effectively than when it is?

Intended audience. Employers, HR leaders and people managers in the tech sector, who control workplace policy and mental health benefits.

2 Data and cleaning

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?

3 Who answered the survey?

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)

4 Message 1: the disclosure gap

4.1 Raising health issues in a job interview (all respondents)

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

4.2 Expected consequences of discussing it with the employer (employees only)

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

4.3 Within-person comparison

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

5 Message 2: the cost of untreated mental health

5.1 Work interference when treated vs not treated effectively

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

5.2 Within-person shift

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 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

5.3 Sensitivity check: respondents with a current or past condition only

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)))
Respondents with a current, past or diagnosed condition, n = 832
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.

6 Linking the two messages

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.

7 Conclusion and key message

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

  • Self-selected online sample recruited through the OSMI community; likely more mental-health-aware than tech workers in general.
  • Mostly US-based and male (see sample profile); findings may not generalise to other countries or to women and gender-diverse workers.
  • Data collected in 2016; attitudes may have shifted since.
  • The “not treated effectively” question is partly hypothetical for respondents who have never been untreated.
  • Self-reported, cross-sectional data: associations, not causal effects.