Psychological Distress and Physical Multimorbidity in England: Evidence from the Health Survey for England 2022¶
Healthcare and Medical Analytics – Group Project¶
Dataset: Health Survey for England 2022 (HSE 2022), UK Data Service, Study Number 9469. NHS England / UKDS.¶
Note on Code Structure: The sections in this Jupyter Notebook are labelled to correspond to specific appendix sections in the main report (e.g. Appendix A.1, Appendix B.2). These labels are used for navigation purposes only and indicate where the corresponding narrative and output appear in the report appendix. The code sections do not represent the appendix content itself, but provide the underlying analysis that supports it.
Note on AI Usage: This analysis was conducted with the assistance of AI tools. Claude (Anthropic) and ChatGPT (OpenAI) were used to support code generation, debugging, and analytical decisions throughout this project. We remain responsible for all analytical choices, interpretations, and conclusions presented in the report. All AI-generated code was reviewed, tested, and validated by myself prior to inclusion.
Imports, Loading, and Cleaning¶
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as mtick
import seaborn as sns
import statsmodels.api as sm
from statsmodels.discrete.discrete_model import NegativeBinomial
from scipy.stats import chi2
import warnings
warnings.filterwarnings('ignore')
df_raw = pd.read_stata('Data/stata/stata13/hse_2022_eul_v1.dta', convert_categoricals=False)
print(f"Dataset loaded successfully.")
print(f"Observations: {df_raw.shape[0]}")
print(f"Variables: {df_raw.shape[1]}")
Dataset loaded successfully. Observations: 9122 Variables: 1883
def clean(series, extra_missing=None):
"""
Replace HSE missing codes (all negative values) with NaN.
HSE uses -1 (not applicable), -8 (don't know), -9 (refused).
extra_missing: additional values to treat as missing
"""
s = series.copy().astype(float)
s[s < 0] = np.nan
if extra_missing:
for v in extra_missing:
s[s == v] = np.nan
return s
df = df_raw.copy()
# Health measures
df['ghq12scr'] = clean(df['ghq12scr'])
df['ghqg2'] = clean(df['ghqg2'])
df['ghq'] = clean(df['ghq'])
df['condlcnt'] = clean(df['condlcnt'])
df['condlcnt2'] = clean(df['condlcnt2'])
df['limlast'] = clean(df['limlast'])
# Key moderator
df['qimd19'] = clean(df['qimd19'])
# Demographics
df['Sex'] = clean(df['Sex'])
df['ag16g10'] = clean(df['ag16g10'])
df['origin2'] = clean(df['origin2'])
# Socioeconomic
df['eqv5'] = clean(df['eqv5'], extra_missing=[-90])
df['nssec3'] = clean(df['nssec3'], extra_missing=[99])
df['topqual3'] = clean(df['topqual3'])
# Lifestyle
df['cigst1_19'] = clean(df['cigst1_19'])
df['totalwug2_22'] = clean(df['totalwug2_22'])
# Geography
df['GOR1'] = clean(df['GOR1'])
df['urban14b'] = clean(df['urban14b'])
# Survey weights
df['wt_int'] = clean(df['wt_int'])
# Physical condition flags (descriptives only)
for v in ['compexp1','compexp3','compexp4',
'compexp6','compexp7','compexp9']:
df[v] = clean(df[v])
# BMI (Appendix A.8)
df['bmisrg3'] = clean(df['bmisrg3'])
print("Missing value coding complete.")
Missing value coding complete.
n_before = len(df)
df = df[df['ag16g10'].notna()].copy()
n_after = len(df)
print(f"Full sample: {n_before:,}")
print(f"Adults 16+ only: {n_after:,}")
print(f"Children excluded: {n_before - n_after:,}")
Full sample: 9,122 Adults 16+ only: 7,729 Children excluded: 1,393
# ------------------------------------------------
# Global plot style
# ------------------------------------------------
import matplotlib.pyplot as plt
import matplotlib.ticker as mtick
import numpy as np
import pandas as pd
plt.rcParams.update({
"figure.dpi": 150,
"savefig.dpi": 300,
"font.size": 11,
"axes.titlesize": 13,
"axes.labelsize": 11,
"legend.fontsize": 10,
"legend.title_fontsize": 10,
"xtick.labelsize": 10,
"ytick.labelsize": 10,
"axes.spines.top": False,
"axes.spines.right": False
})
ghq_palette = {
"GHQ 0 (No distress)": "#2C7FB8",
"GHQ 1-3 (Mild)": "#7FCDBB",
"GHQ 4+ (Probable distress)": "#D95F0E"
}
ghq_markers = {
"GHQ 0 (No distress)": "o",
"GHQ 1-3 (Mild)": "s",
"GHQ 4+ (Probable distress)": "^"
}
source_note = (
"Source: HSE 2022 (NHS England / UKDS, Study 9469). "
"Weighted using wt_int. Analytical sample N=4,735."
)
Appendix A: Data and Variable Construction¶
Appendix A.1: GHQ-12 Psychological Distress Score¶
# ================================================================
# Appendix A.1: GHQ-12 Psychological Distress Score
# ================================================================
# Validated 12-item psychological distress measure, administered
# via self-completion booklet (SC 13+) in HSE 2022.
# HSE provides pre-derived GHQ variables — retained as-is.
# Caseness threshold: score >= 4 (probable psychological distress).
#
# Derived variables used:
# ghq12scr — continuous score 0-12 (primary exposure)
# ghqg2 — grouped: 1=score 0, 2=score 1-3, 3=score 4+
# ghq — binary caseness: 0=score 0-3, 1=score 4+
# ================================================================
# Verify cleaning worked correctly
print("=== Value check after cleaning ===")
print(df['ghq12scr'].value_counts(dropna=False).sort_index())
# Range assertions
assert df['ghq12scr'].dropna().min() >= 0, "GHQ score below 0"
assert df['ghq12scr'].dropna().max() <= 12, "GHQ score above 12"
print("\nRange assertions passed: ghq12scr within [0, 12]")
# N valid / missing
print(f"\nN valid: {df['ghq12scr'].notna().sum():,}")
print(f"N missing: {df['ghq12scr'].isna().sum():,} ({df['ghq12scr'].isna().sum()/len(df)*100:.1f}%)")
# GHQ category labels for figures (used in C)
df['ghq_cat'] = df['ghqg2'].map({
1: '0 (No distress)',
2: '1-3 (Mild)',
3: '4+ (Probable distress)'
})
=== Value check after cleaning === ghq12scr 0.0 3488 1.0 769 2.0 424 3.0 313 4.0 223 5.0 161 6.0 147 7.0 116 8.0 107 9.0 87 10.0 90 11.0 88 12.0 99 NaN 1617 Name: count, dtype: int64 Range assertions passed: ghq12scr within [0, 12] N valid: 6,112 N missing: 1,617 (20.9%)
Appendix A.2: Physical Condition Count¶
# ================================================================
# Appendix A.2: Physical Condition Count
# ================================================================
# condlcnt is a pre-derived HSE variable counting the number of
# grouped longstanding condition categories reported by the
# respondent. Conditions are grouped into ICD-based categories.
# Range observed in cleaned adult sample: 0-6 condition categories.
#
# Derived variables used:
# condlcnt — count of condition categories (primary outcome)
# condlcnt2 — same, capped at 4+ (descriptives)
# limlast — limiting longstanding illness (robustness)
# multimorbid — binary: 1 if condlcnt >= 2 (robustness)
#
# Individual condition flags (descriptives only, not in regression):
# compexp1 — Diabetes
# compexp3 — IHD/stroke/angina
# compexp4 — Hypertension
# compexp6 — COPD/bronchitis
# compexp7 — Asthma
# compexp9 — Arthritis/rheumatism
# ================================================================
# Binary multimorbidity indicator
df['multimorbid'] = (df['condlcnt'] >= 2).astype(float)
df.loc[df['condlcnt'].isna(), 'multimorbid'] = np.nan
# Verify cleaning
print("=== Value check after cleaning ===")
print(df['condlcnt'].value_counts(dropna=False).sort_index())
# Range assertion
assert df['condlcnt'].dropna().min() >= 0, "condlcnt below 0"
print("\nRange assertion passed: condlcnt >= 0")
# N valid / missing
print(f"\nN valid: {df['condlcnt'].notna().sum():,}")
print(f"N missing: {df['condlcnt'].isna().sum():,} ({df['condlcnt'].isna().sum()/len(df)*100:.1f}%)")
# Mean and variance — overdispersion check for model choice justification
mean_cond = df['condlcnt'].mean()
var_cond = df['condlcnt'].var()
print(f"\n=== Overdispersion Check ===")
print(f"Mean: {mean_cond:.3f}")
print(f"Variance: {var_cond:.3f}")
print(f"Variance / Mean: {var_cond/mean_cond:.3f}")
print("(Ratio > 1 indicates overdispersion — supports negative binomial over Poisson)")
# Verify individual condition flag coding before reporting prevalence
print("\n=== Individual Condition Flag Value Counts ===")
flags = {
'compexp1': 'Diabetes',
'compexp3': 'IHD/stroke/angina',
'compexp4': 'Hypertension',
'compexp6': 'COPD/bronchitis',
'compexp7': 'Asthma',
'compexp9': 'Arthritis/rheumatism'
}
for var, label in flags.items():
print(f"\n{label} ({var})")
print(df[var].value_counts(dropna=False).sort_index())
# Prevalence conditional on valid coding confirmed above
print("\n=== Condition Prevalence (adults 16+) ===")
for var, label in flags.items():
n = (df[var] == 1).sum()
pct = n / df[var].notna().sum() * 100
print(f" {label:<25} n={n:,} ({pct:.1f}%)")
=== Value check after cleaning === condlcnt 0.0 4098 1.0 1839 2.0 1023 3.0 481 4.0 201 5.0 72 6.0 8 NaN 7 Name: count, dtype: int64 Range assertion passed: condlcnt >= 0 N valid: 7,722 N missing: 7 (0.1%) === Overdispersion Check === Mean: 0.847 Variance: 1.298 Variance / Mean: 1.533 (Ratio > 1 indicates overdispersion — supports negative binomial over Poisson) === Individual Condition Flag Value Counts === Diabetes (compexp1) compexp1 0.0 7324 1.0 398 NaN 7 Name: count, dtype: int64 IHD/stroke/angina (compexp3) compexp3 0.0 7553 1.0 169 NaN 7 Name: count, dtype: int64 Hypertension (compexp4) compexp4 0.0 7268 1.0 454 NaN 7 Name: count, dtype: int64 COPD/bronchitis (compexp6) compexp6 0.0 7546 1.0 176 NaN 7 Name: count, dtype: int64 Asthma (compexp7) compexp7 0.0 7351 1.0 371 NaN 7 Name: count, dtype: int64 Arthritis/rheumatism (compexp9) compexp9 0.0 7017 1.0 705 NaN 7 Name: count, dtype: int64 === Condition Prevalence (adults 16+) === Diabetes n=398 (5.2%) IHD/stroke/angina n=169 (2.2%) Hypertension n=454 (5.9%) COPD/bronchitis n=176 (2.3%) Asthma n=371 (4.8%) Arthritis/rheumatism n=705 (9.1%)
Appendix A.3: Area Deprivation — IMD Quintile¶
# ================================================================
# Appendix A.3: Area Deprivation — IMD Quintile (qimd19)
# ================================================================
# qimd19 is a pre-derived HSE variable based on the Index of
# Multiple Deprivation 2019 (IMD 2019), linked to respondents
# via their area of residence.
# IMD is an area-level measure of relative deprivation across
# seven domains: income, employment, education, health,
# crime, housing, and environment.
# Quintile 1 = least deprived, Quintile 5 = most deprived.
# This is an area-level, not individual-level, measure.
# Used as key moderator in regression and interaction term.
# ================================================================
# IMD quintile labels for figures
df['imd_label'] = df['qimd19'].map({
1: 'Q1 Least\ndeprived',
2: 'Q2',
3: 'Q3',
4: 'Q4',
5: 'Q5 Most\ndeprived'
})
# Verify cleaning
print("=== Value check after cleaning ===")
print(df['qimd19'].value_counts(dropna=False).sort_index())
# Range assertion
assert df['qimd19'].dropna().min() == 1, "IMD quintile below 1"
assert df['qimd19'].dropna().max() == 5, "IMD quintile above 5"
print("\nRange assertions passed: qimd19 within [1, 5]")
# N valid / missing
print(f"\nN valid: {df['qimd19'].notna().sum():,}")
print(f"N missing: {df['qimd19'].isna().sum():,} ({df['qimd19'].isna().sum()/len(df)*100:.1f}%)")
=== Value check after cleaning === qimd19 1.0 1796 2.0 1631 3.0 1535 4.0 1428 5.0 1339 Name: count, dtype: int64 Range assertions passed: qimd19 within [1, 5] N valid: 7,729 N missing: 0 (0.0%)
Appendix A.4: Demographics¶
# ================================================================
# Appendix A.4: Demographics
# ================================================================
# Three demographic variables used as controls in regression.
#
# Sex (Sex)
# 1 = Male (reference category)
# 2 = Female
# Source: household grid
#
# Age (ag16g10)
# 10-year bands for adults 16+
# 1=16-24, 2=25-34, 3=35-44, 4=45-54,
# 5=55-64, 6=65-74, 7=75+
# Reference category: 16-24
#
# Ethnicity (origin2)
# Grouped ethnic categories — labels verified from HSE
# data dictionary below
# Reference category: White (1)
# ================================================================
# Verify ethnicity labels from data dictionary
reader = pd.io.stata.StataReader('Data/stata/stata13/hse_2022_eul_v1.dta')
value_labels = reader.value_labels()
print("=== origin2 official value labels ===")
for k, v in sorted(value_labels['origin2'].items()):
print(f" {k}: {v}")
# Verify cleaning
print("\n=== Sex (Sex) ===")
print(df['Sex'].value_counts(dropna=False).sort_index())
print("\n=== Age bands (ag16g10) ===")
print(df['ag16g10'].value_counts(dropna=False).sort_index())
print("\n=== Ethnicity (origin2) ===")
print(df['origin2'].value_counts(dropna=False).sort_index())
# Missingness
print("\n=== Missingness ===")
for var in ['Sex', 'ag16g10', 'origin2']:
n_miss = df[var].isna().sum()
pct = n_miss / len(df) * 100
print(f" {var:<12} N missing: {n_miss:,} ({pct:.1f}%)")
=== origin2 official value labels === -9: Refused -8: Don't know -1: Not applicable 1: White 2: Black 3: Asian 4: Mixed/multiple ethnic background 5: Any other ethnic group === Sex (Sex) === Sex 1.0 3488 2.0 4241 Name: count, dtype: int64 === Age bands (ag16g10) === ag16g10 1.0 498 2.0 887 3.0 1149 4.0 1175 5.0 1380 6.0 1452 7.0 1188 Name: count, dtype: int64 === Ethnicity (origin2) === origin2 1.0 6750 2.0 209 3.0 498 4.0 134 5.0 70 NaN 68 Name: count, dtype: int64 === Missingness === Sex N missing: 0 (0.0%) ag16g10 N missing: 0 (0.0%) origin2 N missing: 68 (0.9%)
Appendix A.5 Socioeconomic Status¶
# ================================================================
# Appendix A.5: Socioeconomic Status
# ================================================================
# Three socioeconomic variables used as controls in regression,
# capturing different dimensions of socioeconomic position.
#
# Income (eqv5)
# Equivalised household income quintiles
# Accounts for household size and composition
# 1 = Lowest quintile (<= £19,180) (reference category)
# 2 = Second lowest (> £19,180 <= £27,705)
# 3 = Middle (> £27,705 <= £42,763)
# 4 = Second highest (> £42,763 <= £66,901)
# 5 = Highest (> £66,901)
# Note: -90 treated as missing (age of household member refused)
#
# Social class (nssec3)
# NS-SEC 3-category classification (individual)
# 1 = Managerial and professional (reference category)
# 2 = Intermediate occupations
# 3 = Routine and manual occupations
# Note: value 99 (Other) treated as missing
#
# Education (topqual3)
# Highest educational qualification
# 1 = NVQ4/NVQ5/Degree or equivalent (reference category)
# 2 = Higher education below degree
# 3 = NVQ3/GCE A Level equivalent
# 4 = NVQ2/GCE O Level equivalent
# 5 = NVQ1/CSE other grade equivalent
# 6 = Foreign/other
# 7 = No qualification
# ================================================================
# Verify labels from data dictionary
reader = pd.io.stata.StataReader('Data/stata/stata13/hse_2022_eul_v1.dta')
value_labels = reader.value_labels()
for var, label in [('eqv5','Income quintiles'),
('nssec3','NS-SEC'),
('topqual3','Education')]:
print(f"\n=== {label} ({var}) official labels ===")
if var in value_labels:
for k, v in sorted(value_labels[var].items()):
print(f" {k}: {v}")
# Value counts after cleaning
print("\n=== Income quintiles (eqv5) ===")
print(df['eqv5'].value_counts(dropna=False).sort_index())
print("\n=== NS-SEC (nssec3) ===")
print(df['nssec3'].value_counts(dropna=False).sort_index())
print("\n=== Education (topqual3) ===")
print(df['topqual3'].value_counts(dropna=False).sort_index())
# Missingness
print("\n=== Missingness ===")
for var in ['eqv5', 'nssec3', 'topqual3']:
n_miss = df[var].isna().sum()
pct = n_miss / len(df) * 100
print(f" {var:<12} N missing: {n_miss:,} ({pct:.1f}%)")
=== Income quintiles (eqv5) official labels === -90: Age of household member refused -9: Refused -8: Don't know -1: Not applicable 1: Lowest Quintile (<= £19,180) 2: Second lowest Quintile (> £19,180 <= £27,705) 3: Middle Quintile (> £27,705 <= £42,763) 4: Second highest Quintile (> £42,763 <= £66,901) 5: Highest Quintile (> £66,901) === NS-SEC (nssec3) official labels === -9: Refused -8: Don't know -1: Not applicable 1: Managerial and professional occupations 2: Intermediate occupations 3: Routine and manual occupations 99: Other === Education (topqual3) official labels === -9: Refused -8: Don't know -1: Not applicable 1: NVQ4/NVQ5/Degree or equiv 2: Higher ed below degree 3: NVQ3/GCE A Level equiv 4: NVQ2/GCE O Level equiv 5: NVQ1/CSE other grade equiv 6: Foreign/other 7: No qualification === Income quintiles (eqv5) === eqv5 1.0 1057 2.0 1273 3.0 1210 4.0 1278 5.0 1218 NaN 1693 Name: count, dtype: int64 === NS-SEC (nssec3) === nssec3 1.0 3079 2.0 1686 3.0 2442 NaN 522 Name: count, dtype: int64 === Education (topqual3) === topqual3 1.0 2628 2.0 882 3.0 1130 4.0 1357 5.0 234 6.0 68 7.0 1356 NaN 74 Name: count, dtype: int64 === Missingness === eqv5 N missing: 1,693 (21.9%) nssec3 N missing: 522 (6.8%) topqual3 N missing: 74 (1.0%)
Appendix A.6: Lifestyle¶
# ================================================================
# Appendix A.6: Lifestyle
# ================================================================
# Two lifestyle variables used as controls in regression,
# capturing health behaviours that are associated with both
# psychological distress and physical multimorbidity.
#
# Smoking (cigst1_19)
# Cigarette smoking status
# 1 = Never smoked (reference category)
# 2 = Used to smoke occasionally
# 3 = Used to smoke regularly
# 4 = Current smoker
#
# Alcohol (totalwug2_22)
# Alcohol consumption risk groups (2022 revised units)
# 0 = Non-drinker / not drunk in last 12 months
# (reference category)
# 1 = Lower risk (up to 14 units per week)
# 2 = Increased risk (over 14-35/50 units per week)
# 3 = Higher risk (more than 35/50 units per week)
# ================================================================
# Verify labels from data dictionary
reader = pd.io.stata.StataReader('Data/stata/stata13/hse_2022_eul_v1.dta')
value_labels = reader.value_labels()
for var, label in [('cigst1_19', 'Smoking'),
('totalwug2_22', 'Alcohol')]:
print(f"\n=== {label} ({var}) official labels ===")
if var in value_labels:
for k, v in sorted(value_labels[var].items()):
print(f" {k}: {v}")
# Value counts after cleaning
print("\n=== Smoking (cigst1_19) ===")
print(df['cigst1_19'].value_counts(dropna=False).sort_index())
print("\n=== Alcohol (totalwug2_22) ===")
print(df['totalwug2_22'].value_counts(dropna=False).sort_index())
# Missingness
print("\n=== Missingness ===")
for var in ['cigst1_19', 'totalwug2_22']:
n_miss = df[var].isna().sum()
pct = n_miss / len(df) * 100
print(f" {var:<15} N missing: {n_miss:,} ({pct:.1f}%)")
=== Smoking (cigst1_19) official labels === -9: Refused -8: Don't know -1: Not applicable 1: Never smoked cigarettes at all 2: Used to smoke cigarettes occasionally 3: Used to smoke cigarettes regularly 4: Current cigarette smoker === Alcohol (totalwug2_22) official labels === -9: Refused -8: Don't know -1: Not applicable 0: Non drinker/not in last 12 months 1: Lower risk (up to 14 units) 2: Increased risk (over 14-50/ over 14-35) 3: Higher risk (more than 50/35) === Smoking (cigst1_19) === cigst1_19 1.0 4130 2.0 570 3.0 2011 4.0 917 NaN 101 Name: count, dtype: int64 === Alcohol (totalwug2_22) === totalwug2_22 0.0 1371 1.0 4286 2.0 1438 3.0 362 NaN 272 Name: count, dtype: int64 === Missingness === cigst1_19 N missing: 101 (1.3%) totalwug2_22 N missing: 272 (3.5%)
Appendix A.7: Geography¶
# ================================================================
# Appendix A.7: Geography
# ================================================================
# One geographic variable used as a control in regression,
# capturing regional variation in health outcomes and
# healthcare provision across England.
#
# Region (GOR1)
# Government Office Region (9 regions)
# 1 = North East (reference category)
# 2 = North West
# 3 = Yorkshire and The Humber
# 4 = East Midlands
# 5 = West Midlands
# 6 = East of England
# 7 = London
# 8 = South East
# 9 = South West
# ================================================================
# Verify labels from data dictionary
reader = pd.io.stata.StataReader('Data/stata/stata13/hse_2022_eul_v1.dta')
value_labels = reader.value_labels()
print("=== Region (GOR1) official labels ===")
if 'GOR1' in value_labels:
for k, v in sorted(value_labels['GOR1'].items()):
print(f" {k}: {v}")
# Value counts after cleaning
print("\n=== Region (GOR1) ===")
print(df['GOR1'].value_counts(dropna=False).sort_index())
# Missingness
n_miss = df['GOR1'].isna().sum()
pct = n_miss / len(df) * 100
print(f"\nN valid: {df['GOR1'].notna().sum():,}")
print(f"N missing: {n_miss:,} ({pct:.1f}%)")
=== Region (GOR1) official labels === -9: Refused -8: Don't know -1: Not applicable 1: E12000001 North East 2: E12000002 North West 3: E12000003 Yorkshire and The Humber 4: E12000004 East Midlands 5: E12000005 West Midlands 6: E12000006 East of England 7: E12000007 London 8: E12000008 South East 9: E12000009 South West === Region (GOR1) === GOR1 1.0 804 2.0 911 3.0 780 4.0 569 5.0 720 6.0 849 7.0 826 8.0 1367 9.0 903 Name: count, dtype: int64 N valid: 7,729 N missing: 0 (0.0%)
Appendix A.8: BMI¶
# ================================================================
# Appendix A.8: BMI — Considered and Excluded
# ================================================================
# BMI was considered as a lifestyle control variable given its
# established association with both psychological distress and
# physical multimorbidity. However, it was excluded from the
# regression model due to prohibitive missingness.
#
# bmisrg3 — self-reported BMI grouped
# 1 = Underweight/normal
# 2 = Overweight
# 3 = Obese
# ================================================================
# Check missingness
print("=== BMI (bmisrg3) value counts ===")
print(df['bmisrg3'].value_counts(dropna=False).sort_index())
n_miss = df['bmisrg3'].isna().sum()
pct = n_miss / len(df) * 100
print(f"\nN valid: {df['bmisrg3'].notna().sum():,}")
print(f"N missing: {n_miss:,} ({pct:.1f}%)")
# Show impact on analytical sample if BMI were included
reg_vars_with_bmi = [
'ghq12scr', 'condlcnt', 'qimd19', 'Sex', 'ag16g10',
'origin2', 'eqv5', 'nssec3', 'topqual3',
'cigst1_19', 'totalwug2_22', 'GOR1', 'bmisrg3'
]
n_with_bmi = df.dropna(subset=reg_vars_with_bmi).shape[0]
reg_vars_without_bmi = [v for v in reg_vars_with_bmi if v != 'bmisrg3']
n_without_bmi = df.dropna(subset=reg_vars_without_bmi).shape[0]
print(f"\n=== Impact of Including BMI ===")
print(f"Analytical sample without BMI: {n_without_bmi:,}")
print(f"Analytical sample with BMI: {n_with_bmi:,}")
print(f"Sample loss from adding BMI: {n_without_bmi - n_with_bmi:,} ({(n_without_bmi - n_with_bmi)/n_without_bmi*100:.1f}%)")
print(f"\nBMI excluded on these grounds.")
=== BMI (bmisrg3) value counts === bmisrg3 1.0 1001 2.0 872 3.0 596 NaN 5260 Name: count, dtype: int64 N valid: 2,469 N missing: 5,260 (68.1%) === Impact of Including BMI === Analytical sample without BMI: 4,735 Analytical sample with BMI: 1,075 Sample loss from adding BMI: 3,660 (77.3%) BMI excluded on these grounds.
Appendix B: Sample Construction and Missingness¶
Appendix B.1: Sample Construction¶
# ================================================================
# Appendix B.1: Sample Construction
# ================================================================
reg_vars = [
'ghq12scr', 'condlcnt', 'qimd19', 'Sex', 'ag16g10',
'origin2', 'eqv5', 'nssec3', 'topqual3',
'cigst1_19', 'totalwug2_22', 'GOR1'
]
# Define analytical sample
df_analytic = df.dropna(subset=reg_vars).copy()
df_excluded = df[~df.index.isin(df_analytic.index)].copy()
# Sequential sample construction table
print("=== Table B.1: Sequential Sample Construction ===")
print(f"{'Step':<35} {'N Remaining':>12} {'N Lost':>10}")
print("-" * 60)
running = df.copy()
print(f"{'Full adult sample (16+)':<35} {len(running):>12} {'—':>10}")
var_order = [
('GHQ-12', 'ghq12scr'),
('Condition count', 'condlcnt'),
('IMD quintile', 'qimd19'),
('Income', 'eqv5'),
('NS-SEC', 'nssec3'),
('Education', 'topqual3'),
('Smoking', 'cigst1_19'),
('Alcohol', 'totalwug2_22'),
('Sex', 'Sex'),
('Age', 'ag16g10'),
('Ethnicity', 'origin2'),
('Region', 'GOR1'),
]
for label, var in var_order:
before = len(running)
running = running.dropna(subset=[var])
after = len(running)
lost = before - after
print(f"{'Drop missing: '+label:<35} {after:>12} {lost:>10}")
print("-" * 60)
print(f"{'Final analytical sample':<35} {len(df_analytic):>12}")
=== Table B.1: Sequential Sample Construction === Step N Remaining N Lost ------------------------------------------------------------ Full adult sample (16+) 7729 — Drop missing: GHQ-12 6112 1617 Drop missing: Condition count 6109 3 Drop missing: IMD quintile 6109 0 Drop missing: Income 5041 1068 Drop missing: NS-SEC 4841 200 Drop missing: Education 4830 11 Drop missing: Smoking 4817 13 Drop missing: Alcohol 4739 78 Drop missing: Sex 4739 0 Drop missing: Age 4739 0 Drop missing: Ethnicity 4735 4 Drop missing: Region 4735 0 ------------------------------------------------------------ Final analytical sample 4735
Appendix B.2: Missing Data Analysis¶
# ================================================================
# Appendix B.2: Missing Data Analysis
# ================================================================
compare_vars = [
('Age band', 'ag16g10'),
('Sex', 'Sex'),
('IMD quintile', 'qimd19'),
('Ethnicity', 'origin2'),
('Condition count', 'condlcnt'),
]
# --- Table B.2: Missingness Rates ---
print("=== Table B.2: Variable Missingness Rates (Adult Sample, N=7,729) ===")
print(f"{'Variable':<30} {'N Valid':>10} {'N Missing':>10} {'% Missing':>10}")
print("-" * 63)
miss_vars = [
('GHQ-12 (ghq12scr)', 'ghq12scr'),
('Condition count (condlcnt)', 'condlcnt'),
('IMD quintile (qimd19)', 'qimd19'),
('Income (eqv5)', 'eqv5'),
('NS-SEC (nssec3)', 'nssec3'),
('Education (topqual3)', 'topqual3'),
('Smoking (cigst1_19)', 'cigst1_19'),
('Alcohol (totalwug2_22)', 'totalwug2_22'),
('Sex', 'Sex'),
('Age (ag16g10)', 'ag16g10'),
('Ethnicity (origin2)', 'origin2'),
('Region (GOR1)', 'GOR1'),
]
for label, var in miss_vars:
n_valid = df[var].notna().sum()
n_miss = df[var].isna().sum()
pct = n_miss / len(df) * 100
print(f"{label:<30} {n_valid:>10,} {n_miss:>10,} {pct:>9.1f}%")
# --- Table B.3: Who is missing GHQ-12? ---
print("\n=== Table B.3: Characteristics by GHQ-12 Response Status ===")
print(f"{'Variable':<25} {'Has GHQ (n=6,112)':>20} {'Missing GHQ (n=1,617)':>22} {'Difference':>12}")
print("-" * 82)
ghq_present = df[df['ghq12scr'].notna()]
ghq_missing = df[df['ghq12scr'].isna()]
for label, var in compare_vars:
m_pres = ghq_present[var].mean()
m_miss = ghq_missing[var].mean()
diff = m_pres - m_miss
print(f"{label:<25} {m_pres:>20.3f} {m_miss:>22.3f} {diff:>+12.3f}")
# --- Table B.4: Who is missing income? ---
print("\n=== Table B.4: Characteristics by Income Response Status ===")
print(f"{'Variable':<25} {'Has Income (n=6,036)':>22} {'Missing Income (n=1,693)':>25} {'Difference':>12}")
print("-" * 87)
inc_present = df[df['eqv5'].notna()]
inc_missing = df[df['eqv5'].isna()]
for label, var in compare_vars:
m_pres = inc_present[var].mean()
m_miss = inc_missing[var].mean()
diff = m_pres - m_miss
print(f"{label:<25} {m_pres:>22.3f} {m_miss:>25.3f} {diff:>+12.3f}")
=== Table B.2: Variable Missingness Rates (Adult Sample, N=7,729) === Variable N Valid N Missing % Missing --------------------------------------------------------------- GHQ-12 (ghq12scr) 6,112 1,617 20.9% Condition count (condlcnt) 7,722 7 0.1% IMD quintile (qimd19) 7,729 0 0.0% Income (eqv5) 6,036 1,693 21.9% NS-SEC (nssec3) 7,207 522 6.8% Education (topqual3) 7,655 74 1.0% Smoking (cigst1_19) 7,628 101 1.3% Alcohol (totalwug2_22) 7,457 272 3.5% Sex 7,729 0 0.0% Age (ag16g10) 7,729 0 0.0% Ethnicity (origin2) 7,661 68 0.9% Region (GOR1) 7,729 0 0.0% === Table B.3: Characteristics by GHQ-12 Response Status === Variable Has GHQ (n=6,112) Missing GHQ (n=1,617) Difference ---------------------------------------------------------------------------------- Age band 4.502 4.224 +0.278 Sex 1.552 1.535 +0.017 IMD quintile 2.840 2.913 -0.072 Ethnicity 1.229 1.315 -0.087 Condition count 0.882 0.712 +0.170 === Table B.4: Characteristics by Income Response Status === Variable Has Income (n=6,036) Missing Income (n=1,693) Difference --------------------------------------------------------------------------------------- Age band 4.397 4.611 -0.214 Sex 1.545 1.561 -0.016 IMD quintile 2.814 3.002 -0.187 Ethnicity 1.225 1.323 -0.098 Condition count 0.842 0.863 -0.021
Appendix C: Descriptive Statistics and Figures¶
Appendix C.1: Sample Characteristics Table (Weighted)¶
# ================================================================
# Appendix C.1: Sample Characteristics Table
# ================================================================
def wmean(series, weights):
mask = series.notna() & weights.notna()
return np.average(series[mask], weights=weights[mask])
def wstd(series, weights):
mask = series.notna() & weights.notna()
avg = np.average(series[mask], weights=weights[mask])
variance = np.average((series[mask] - avg)**2, weights=weights[mask])
return np.sqrt(variance)
def wpct(series, value, weights):
mask = series.notna() & weights.notna()
w_val = weights[mask & (series == value)].sum()
w_tot = weights[mask].sum()
return w_val / w_tot * 100
w = df_analytic['wt_int']
print("=== Table C.1: Sample Characteristics (Analytical Sample, N=4,735) ===")
# --- Panel A: Continuous Variables ---
print("\nPanel A: Continuous Variables")
print(f"{'Variable':<30} {'W.Mean':>8} {'W.SD':>8} {'Min':>6} {'Max':>6}")
print("-" * 62)
for label, var in [('GHQ-12 score (0-12)', 'ghq12scr'),
('Condition count', 'condlcnt')]:
wm = wmean(df_analytic[var], w)
sd = wstd(df_analytic[var], w)
mn = df_analytic[var].min()
mx = df_analytic[var].max()
print(f"{label:<30} {wm:>8.3f} {sd:>8.3f} {mn:>6.0f} {mx:>6.0f}")
# --- Panel B: Categorical Variables ---
print("\nPanel B: Categorical Variables")
print(f"{'Variable':<35} {'Weighted %':>12}")
print("-" * 50)
# Sex
print("\nSex")
for val, label in [(1,'Male'), (2,'Female')]:
pct = wpct(df_analytic['Sex'], val, w)
print(f" {label:<33} {pct:>11.1f}%")
# Age group
print("\nAge group")
for val, label in [(1,'16-24'),(2,'25-34'),(3,'35-44'),(4,'45-54'),
(5,'55-64'),(6,'65-74'),(7,'75+')]:
pct = wpct(df_analytic['ag16g10'], val, w)
print(f" {label:<33} {pct:>11.1f}%")
# Ethnicity
print("\nEthnicity")
for val, label in [(1,'White'),(2,'Black'),(3,'Asian'),
(4,'Mixed/multiple'),(5,'Any other')]:
pct = wpct(df_analytic['origin2'], val, w)
print(f" {label:<33} {pct:>11.1f}%")
# IMD quintile
print("\nIMD quintile")
for val, label in [(1,'Q1 (Least deprived)'),(2,'Q2'),(3,'Q3'),
(4,'Q4'),(5,'Q5 (Most deprived)')]:
pct = wpct(df_analytic['qimd19'], val, w)
print(f" {label:<33} {pct:>11.1f}%")
# Income quintile
print("\nIncome quintile")
for val, label in [(1,'Q1 Lowest (<=£19,180)'),
(2,'Q2 (£19,181-£27,705)'),
(3,'Q3 (£27,706-£42,763)'),
(4,'Q4 (£42,764-£66,901)'),
(5,'Q5 Highest (>£66,901)')]:
pct = wpct(df_analytic['eqv5'], val, w)
print(f" {label:<33} {pct:>11.1f}%")
# NS-SEC
print("\nNS-SEC")
for val, label in [(1,'Managerial/professional'),
(2,'Intermediate'),
(3,'Routine/manual')]:
pct = wpct(df_analytic['nssec3'], val, w)
print(f" {label:<33} {pct:>11.1f}%")
# Education
print("\nEducation")
for val, label in [(1,'Degree or equivalent'),
(2,'Higher ed below degree'),
(3,'A Level equivalent'),
(4,'O Level equivalent'),
(5,'NVQ1/CSE'),
(6,'Foreign/other'),
(7,'No qualification')]:
pct = wpct(df_analytic['topqual3'], val, w)
print(f" {label:<33} {pct:>11.1f}%")
# Smoking
print("\nSmoking status")
for val, label in [(1,'Never smoked'),
(2,'Ex-occasional smoker'),
(3,'Ex-regular smoker'),
(4,'Current smoker')]:
pct = wpct(df_analytic['cigst1_19'], val, w)
print(f" {label:<33} {pct:>11.1f}%")
# Alcohol
print("\nAlcohol risk")
for val, label in [(0,'Non-drinker'),
(1,'Lower risk'),
(2,'Increased risk'),
(3,'Higher risk')]:
pct = wpct(df_analytic['totalwug2_22'], val, w)
print(f" {label:<33} {pct:>11.1f}%")
# GHQ caseness
print("\nGHQ-12 caseness")
for val, label in [(0,'Score 0-3 (no caseness)'),
(1,'Score 4+ (probable distress)')]:
pct = wpct(df_analytic['ghq'], val, w)
print(f" {label:<33} {pct:>11.1f}%")
# Multimorbidity
print("\nMultimorbidity (2+ conditions)")
for val, label in [(0,'No (0-1 conditions)'),
(1,'Yes (2+ conditions)')]:
pct = wpct(df_analytic['multimorbid'], val, w)
print(f" {label:<33} {pct:>11.1f}%")
=== Table C.1: Sample Characteristics (Analytical Sample, N=4,735) === Panel A: Continuous Variables Variable W.Mean W.SD Min Max -------------------------------------------------------------- GHQ-12 score (0-12) 1.653 2.839 0 12 Condition count 0.759 1.093 0 6 Panel B: Categorical Variables Variable Weighted % -------------------------------------------------- Sex Male 48.6% Female 51.4% Age group 16-24 7.7% 25-34 17.7% 35-44 17.0% 45-54 16.4% 55-64 17.6% 65-74 13.6% 75+ 10.0% Ethnicity White 87.6% Black 3.1% Asian 6.2% Mixed/multiple 2.2% Any other 0.9% IMD quintile Q1 (Least deprived) 24.1% Q2 22.2% Q3 20.5% Q4 17.9% Q5 (Most deprived) 15.2% Income quintile Q1 Lowest (<=£19,180) 16.2% Q2 (£19,181-£27,705) 19.2% Q3 (£27,706-£42,763) 19.1% Q4 (£42,764-£66,901) 22.1% Q5 Highest (>£66,901) 23.5% NS-SEC Managerial/professional 46.4% Intermediate 21.6% Routine/manual 32.0% Education Degree or equivalent 41.2% Higher ed below degree 12.8% A Level equivalent 17.2% O Level equivalent 15.4% NVQ1/CSE 2.5% Foreign/other 0.5% No qualification 10.4% Smoking status Never smoked 53.8% Ex-occasional smoker 8.5% Ex-regular smoker 25.4% Current smoker 12.3% Alcohol risk Non-drinker 14.9% Lower risk 58.9% Increased risk 21.0% Higher risk 5.3% GHQ-12 caseness Score 0-3 (no caseness) 82.6% Score 4+ (probable distress) 17.4% Multimorbidity (2+ conditions) No (0-1 conditions) 79.7% Yes (2+ conditions) 20.3%
Appendix C.2: Weighted vs Unweighted Comparison¶
# ================================================================
# Appendix C.2: Weighted vs Unweighted Comparison
# ================================================================
print("=== Table C.2: Weighted vs Unweighted Comparison ===")
print(f"{'Variable':<35} {'Unweighted':>12} {'Weighted':>12} {'Difference':>12}")
print("-" * 74)
# Continuous variables
print("\nContinuous variables (mean)")
for label, var in [('GHQ-12 score', 'ghq12scr'),
('Condition count', 'condlcnt')]:
uw = df_analytic[var].mean()
wt = wmean(df_analytic[var], w)
print(f" {label:<33} {uw:>12.3f} {wt:>12.3f} {wt-uw:>+12.3f}")
# GHQ caseness
print("\nGHQ caseness (% score 4+)")
uw_ghq = (df_analytic['ghq'] == 1).sum() / df_analytic['ghq'].notna().sum() * 100
wt_ghq = wpct(df_analytic['ghq'], 1, w)
print(f" {'GHQ 4+ (probable distress)':<33} {uw_ghq:>11.1f}% {wt_ghq:>11.1f}% {wt_ghq-uw_ghq:>+11.1f}%")
# Multimorbidity
print("\nMultimorbidity (% 2+ conditions)")
uw_mm = (df_analytic['multimorbid'] == 1).sum() / df_analytic['multimorbid'].notna().sum() * 100
wt_mm = wpct(df_analytic['multimorbid'], 1, w)
print(f" {'2+ conditions':<33} {uw_mm:>11.1f}% {wt_mm:>11.1f}% {wt_mm-uw_mm:>+11.1f}%")
# IMD quintile distribution
print("\nIMD quintile (%)")
for val, label in [(1,'Q1 (Least deprived)'),(2,'Q2'),(3,'Q3'),
(4,'Q4'),(5,'Q5 (Most deprived)')]:
uw = (df_analytic['qimd19'] == val).sum() / len(df_analytic) * 100
wt = wpct(df_analytic['qimd19'], val, w)
print(f" {label:<33} {uw:>11.1f}% {wt:>11.1f}% {wt-uw:>+11.1f}%")
# Income quintile distribution
print("\nIncome quintile (%)")
for val, label in [(1,'Q1 Lowest'),(2,'Q2'),(3,'Q3'),
(4,'Q4'),(5,'Q5 Highest')]:
uw = (df_analytic['eqv5'] == val).sum() / len(df_analytic) * 100
wt = wpct(df_analytic['eqv5'], val, w)
print(f" {label:<33} {uw:>11.1f}% {wt:>11.1f}% {wt-uw:>+11.1f}%")
=== Table C.2: Weighted vs Unweighted Comparison === Variable Unweighted Weighted Difference -------------------------------------------------------------------------- Continuous variables (mean) GHQ-12 score 1.654 1.653 -0.000 Condition count 0.867 0.759 -0.108 GHQ caseness (% score 4+) GHQ 4+ (probable distress) 17.2% 17.4% +0.1% Multimorbidity (% 2+ conditions) 2+ conditions 23.8% 20.3% -3.5% IMD quintile (%) Q1 (Least deprived) 24.7% 24.1% -0.6% Q2 22.2% 22.2% +0.1% Q3 19.4% 20.5% +1.1% Q4 17.7% 17.9% +0.2% Q5 (Most deprived) 16.0% 15.2% -0.8% Income quintile (%) Q1 Lowest 15.8% 16.2% +0.4% Q2 21.5% 19.2% -2.3% Q3 20.1% 19.1% -1.0% Q4 21.8% 22.1% +0.3% Q5 Highest 20.9% 23.5% +2.6%
Appendix C.3: GHQ-12 Distribution¶
# ================================================================
# Appendix C.3: GHQ-12 Distribution
# ================================================================
from scipy.stats import skew
ghq_vals = df_analytic['ghq12scr'].dropna()
w_ghq = df_analytic.loc[ghq_vals.index, 'wt_int']
# Summary statistics
print("=== GHQ-12 Summary Statistics (Analytical Sample) ===")
print(f"N valid: {len(ghq_vals):,}")
print(f"Weighted mean: {wmean(df_analytic['ghq12scr'], w):.3f}")
print(f"Median: {ghq_vals.median():.0f}")
print(f"SD (unweighted): {ghq_vals.std():.3f}")
print(f"Skewness: {skew(ghq_vals):.3f}")
print(f"Proportion at 0: {(ghq_vals == 0).mean() * 100:.1f}%")
print(f"Proportion at 12: {(ghq_vals == 12).mean() * 100:.1f}%")
print(f"Weighted caseness: {wpct(df_analytic['ghq'], 1, w):.1f}%")
# Weighted histogram
fig, ax = plt.subplots(figsize=(9, 5))
ax.hist(ghq_vals, bins=13, range=(-0.5, 12.5),
weights=w_ghq,
color='#4393c3', edgecolor='white', linewidth=0.5, alpha=0.85)
ax.axvline(x=3.5, color='#d6604d', linestyle='--', linewidth=1.5,
label='Caseness threshold (score ≥ 4)')
ax.set_xlabel('GHQ-12 Score (0 = no distress, 12 = severe distress)', fontsize=11)
ax.set_ylabel('Weighted Frequency', fontsize=11)
ax.set_title('Distribution of GHQ-12 Psychological Distress Scores\nAdults Aged 16+ in England (HSE 2022)',
fontsize=11, fontweight='bold')
ax.set_xticks(range(0, 13))
ax.legend(fontsize=10)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.grid(axis='y', alpha=0.3, linestyle='--')
fig.text(0.12, 0.01,
'Source: Health Survey for England 2022 (NHS England / UK Data Service, Study 9469). Weighted using wt_int.',
fontsize=8.5, color='#555555')
plt.tight_layout()
plt.savefig('figure_c3_ghq_distribution.png', dpi=200, bbox_inches='tight')
plt.show()
print("\nFigure C.3 saved.")
=== GHQ-12 Summary Statistics (Analytical Sample) === N valid: 4,735 Weighted mean: 1.653 Median: 0 SD (unweighted): 2.867 Skewness: 2.038 Proportion at 0: 58.1% Proportion at 12: 1.4% Weighted caseness: 17.4%
Figure C.3 saved.
Appendix C.4: Condition Count Distribution¶
# ================================================================
# Appendix C.4: Condition Count Distribution
# ================================================================
from scipy.stats import skew
cond_vals = df_analytic['condlcnt'].dropna()
w_cond = df_analytic.loc[cond_vals.index, 'wt_int']
# Summary statistics
print("=== Condition Count Summary Statistics (Analytical Sample) ===")
print(f"N valid: {len(cond_vals):,}")
print(f"Median: {cond_vals.median():.0f}")
print(f"SD (unweighted): {cond_vals.std():.3f}")
print(f"Skewness: {skew(cond_vals):.3f}")
print(f"Proportion at 0: {(cond_vals == 0).mean() * 100:.1f}%")
print(f"Proportion at 6: {(cond_vals == 6).mean() * 100:.1f}%")
print(f"Weighted mean: {wmean(df_analytic['condlcnt'], w):.3f}")
print(f"Variance: {cond_vals.var():.3f}")
print(f"Variance/Mean: {cond_vals.var() / cond_vals.mean():.3f}")
print(f"Weighted multimorbidity (2+): {wpct(df_analytic['multimorbid'], 1, w):.1f}%")
# Weighted histogram
fig, ax = plt.subplots(figsize=(9, 5))
ax.hist(cond_vals, bins=7, range=(-0.5, 6.5),
weights=w_cond,
color='#4393c3', edgecolor='white', linewidth=0.5, alpha=0.85)
ax.axvline(x=1.5, color='#d6604d', linestyle='--', linewidth=1.5,
label='Multimorbidity threshold (2+ conditions)')
ax.set_xlabel('Number of Physical Longstanding Condition Categories', fontsize=11)
ax.set_ylabel('Weighted Frequency', fontsize=11)
ax.set_title('Distribution of Physical Condition Count\nAdults Aged 16+ in England (HSE 2022)',
fontsize=11, fontweight='bold')
ax.set_xticks(range(0, 7))
ax.legend(fontsize=10)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.grid(axis='y', alpha=0.3, linestyle='--')
fig.text(0.12, 0.01,
'Source: Health Survey for England 2022 (NHS England / UK Data Service, Study 9469). Weighted using wt_int.',
fontsize=8.5, color='#555555')
plt.tight_layout()
plt.savefig('figure_c4_condition_distribution.png', dpi=200, bbox_inches='tight')
plt.show()
print("\nFigure C.4 saved.")
=== Condition Count Summary Statistics (Analytical Sample) === N valid: 4,735 Median: 0 SD (unweighted): 1.155 Skewness: 1.416 Proportion at 0: 52.5% Proportion at 6: 0.1% Weighted mean: 0.759 Variance: 1.333 Variance/Mean: 1.538 Weighted multimorbidity (2+): 20.3%
Figure C.4 saved.
Appendix C.5: Condition Prevalence by GHQ Group¶
# ================================================================
# Appendix C.5: Condition Prevalence by GHQ Group
# ================================================================
df_analytic['ghq_cat'] = df_analytic['ghqg2'].map({
1: '0 (No distress)',
2: '1-3 (Mild)',
3: '4+ (Probable distress)'
})
flags = {
'compexp1': 'Diabetes',
'compexp3': 'IHD/stroke/angina',
'compexp4': 'Hypertension',
'compexp6': 'COPD/bronchitis',
'compexp7': 'Asthma',
'compexp9': 'Arthritis/rheumatism'
}
print("=== Table C.5: Condition Prevalence by GHQ-12 Group (Weighted %) ===")
print(f"{'Condition':<25} {'GHQ 0':>10} {'GHQ 1-3':>10} {'GHQ 4+':>10} {'Total':>10}")
print("-" * 60)
for var, label in flags.items():
row = []
for ghq_val in [1, 2, 3]:
subset = df_analytic[df_analytic['ghqg2'] == ghq_val]
w_sub = subset['wt_int']
pct = wpct(subset[var], 1, w_sub)
row.append(pct)
total = wpct(df_analytic[var], 1, w)
print(f"{label:<25} {row[0]:>9.1f}% {row[1]:>9.1f}% {row[2]:>9.1f}% {total:>9.1f}%")
# Weighted mean condition count by GHQ group
print("\n=== Mean Condition Count by GHQ Group (Weighted) ===")
print(f"{'GHQ Group':<25} {'N':>8} {'W.Mean condlcnt':>17} {'W.% Multimorbid':>17}")
print("-" * 70)
for ghq_val, label in [(1,'0 (No distress)'),
(2,'1-3 (Mild)'),
(3,'4+ (Probable distress)')]:
subset = df_analytic[df_analytic['ghqg2'] == ghq_val]
w_sub = subset['wt_int']
n = len(subset)
wm = wmean(subset['condlcnt'], w_sub)
mm = wpct(subset['multimorbid'], 1, w_sub)
print(f"{label:<25} {n:>8,} {wm:>17.3f} {mm:>16.1f}%")
=== Table C.5: Condition Prevalence by GHQ-12 Group (Weighted %) === Condition GHQ 0 GHQ 1-3 GHQ 4+ Total ------------------------------------------------------------ Diabetes 4.3% 3.3% 5.7% 4.3% IHD/stroke/angina 1.5% 1.1% 2.2% 1.5% Hypertension 5.9% 3.8% 4.7% 5.2% COPD/bronchitis 1.3% 1.8% 2.3% 1.6% Asthma 3.8% 6.1% 7.2% 5.0% Arthritis/rheumatism 6.2% 6.8% 12.5% 7.5% === Mean Condition Count by GHQ Group (Weighted) === GHQ Group N W.Mean condlcnt W.% Multimorbid ---------------------------------------------------------------------- 0 (No distress) 2,753 0.594 15.3% 1-3 (Mild) 1,166 0.783 21.0% 4+ (Probable distress) 816 1.270 36.1%
print("=== Table C.5c: Mean Condition Count and Multimorbidity by IMD Quintile (Weighted) ===")
print(f"{'IMD Quintile':<25} {'N':>8} {'W.Mean condlcnt':>17} {'W.% Multimorbid':>17}")
print("-" * 70)
for q, lab in [(1,'Q1 (Least deprived)'),(2,'Q2'),(3,'Q3'),
(4,'Q4'),(5,'Q5 (Most deprived)')]:
subset = df_analytic[df_analytic['qimd19'] == q]
w_sub = subset['wt_int']
n = len(subset)
wm = wmean(subset['condlcnt'], w_sub)
mm = wpct(subset['multimorbid'], 1, w_sub)
print(f"{lab:<25} {n:>8,} {wm:>17.3f} {mm:>16.1f}%")
=== Table C.5c: Mean Condition Count and Multimorbidity by IMD Quintile (Weighted) === IMD Quintile N W.Mean condlcnt W.% Multimorbid ---------------------------------------------------------------------- Q1 (Least deprived) 1,168 0.623 15.6% Q2 1,051 0.759 21.1% Q3 920 0.758 20.1% Q4 837 0.744 19.8% Q5 (Most deprived) 759 0.991 27.6%
Appendix C.6: Figures¶
# ================================================================
# Appendix C.6: Descriptive Figures
# ================================================================
import textwrap
source_note_fig1 = (
"Source: NHS England (2022) Health Survey for England, 2022 [data collection]. "
"UK Data Service, SN 9469. Weighted estimates using HSE 2022 interview weights (wt_int). "
"GHQ-12 caseness threshold: score ≥ 4 indicates probable psychological distress "
"(Goldberg et al., 1997). IMD: Index of Multiple Deprivation 2019 (MHCLG, 2019)."
)
source_note_c61 = (
"Source: NHS England (2022) Health Survey for England, 2022 [data collection]. "
"UK Data Service, SN 9469. Weighted using wt_int. "
"Conditions derived from HSE compexp variables (pre-coded ICD-based condition flags)."
)
source_note_c62 = (
"Source: NHS England (2022) Health Survey for England, 2022 [data collection]. "
"UK Data Service, SN 9469. Weighted using wt_int. "
"IMD: Index of Multiple Deprivation 2019 (MHCLG, 2019)."
)
GHQ_COLORS = {
"GHQ 0 (No distress)": "#2C7FB8",
"GHQ 1-3 (Mild)": "#7FCDBB",
"GHQ 4+ (Probable distress)": "#D95F0E"
}
GHQ_MARKERS = {
"GHQ 0 (No distress)": "o",
"GHQ 1-3 (Mild)": "s",
"GHQ 4+ (Probable distress)": "^"
}
# ================================================================
# Figure 1 (Main Report Figure)
# ================================================================
line_data = []
for ghq_val, ghq_lab in [
(1, "GHQ 0 (No distress)"),
(2, "GHQ 1-3 (Mild)"),
(3, "GHQ 4+ (Probable distress)")
]:
for imd_val in range(1, 6):
subset = df_analytic[
(df_analytic["ghqg2"] == ghq_val) &
(df_analytic["qimd19"] == imd_val)
]
line_data.append({
"ghq": ghq_lab,
"imd": imd_val,
"mean_cond": wmean(subset["condlcnt"], subset["wt_int"]),
"n": len(subset)
})
line_df = pd.DataFrame(line_data)
fig, ax = plt.subplots(figsize=(9.5, 5.8))
for ghq_lab in GHQ_COLORS.keys():
sub = line_df[line_df["ghq"] == ghq_lab]
ax.plot(
sub["imd"], sub["mean_cond"],
color=GHQ_COLORS[ghq_lab],
marker=GHQ_MARKERS[ghq_lab],
linewidth=2.6, markersize=7.5,
label=ghq_lab
)
ghq4 = line_df[line_df["ghq"] == "GHQ 4+ (Probable distress)"]
for _, row in ghq4.iterrows():
ax.text(
row["imd"], row["mean_cond"] + 0.045,
f"{row['mean_cond']:.2f}",
ha="center", va="bottom", fontsize=9,
color=GHQ_COLORS["GHQ 4+ (Probable distress)"]
)
ax.set_xticks(range(1, 6))
ax.set_xticklabels(["Q1\nLeast deprived", "Q2", "Q3", "Q4", "Q5\nMost deprived"])
ax.set_ylim(0.45, 1.85)
ax.set_xlabel("Area Deprivation Quintile (IMD 2019)")
ax.set_ylabel("Weighted Mean Physical Condition Count")
ax.set_title(
"Figure 1: Mean Physical Condition Count by Psychological Distress and Area Deprivation\n"
"Adults Aged 16+ in England, HSE 2022",
fontweight="bold", pad=12
)
ax.legend(title="GHQ-12 Distress Level", loc="upper left",
frameon=True, framealpha=0.95)
ax.grid(axis="y", linestyle="--", alpha=0.25)
ax.grid(axis="x", linestyle="--", alpha=0.10)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
wrapped_fig1 = "\n".join(textwrap.wrap(source_note_fig1, width=110))
fig.text(0.12, 0.01, wrapped_fig1, fontsize=8, color="#555555")
plt.tight_layout(rect=[0, 0.10, 1, 1])
plt.savefig("figure1.png", dpi=300, bbox_inches="tight")
plt.show()
print("Figure 1 saved.")
# ================================================================
# Figure C.6.1 (Appendix)
# Physical Condition Prevalence Heatmap by GHQ Group
# ================================================================
flags = {
"compexp1": "Diabetes",
"compexp3": "IHD/stroke/angina",
"compexp4": "Hypertension",
"compexp6": "COPD/bronchitis",
"compexp7": "Asthma",
"compexp9": "Arthritis/rheumatism"
}
conditions = list(flags.keys())
cond_labels = list(flags.values())
ghq_groups = [1, 2, 3]
ghq_labels = [
"GHQ 0\nNo distress",
"GHQ 1-3\nMild",
"GHQ 4+\nProbable distress"
]
heatmap_data = np.zeros((len(conditions), len(ghq_groups)))
for i, var in enumerate(conditions):
for j, ghq_val in enumerate(ghq_groups):
subset = df_analytic[df_analytic["ghqg2"] == ghq_val]
heatmap_data[i, j] = wpct(subset[var], 1, subset["wt_int"])
fig, ax = plt.subplots(figsize=(8.2, 5.8))
im = ax.imshow(heatmap_data, cmap="YlOrBr", aspect="auto",
vmin=0, vmax=np.nanmax(heatmap_data))
ax.set_xticks(np.arange(len(ghq_labels)))
ax.set_xticklabels(ghq_labels)
ax.set_yticks(np.arange(len(cond_labels)))
ax.set_yticklabels(cond_labels)
for i in range(len(conditions)):
for j in range(len(ghq_groups)):
value = heatmap_data[i, j]
ax.text(j, i, f"{value:.1f}%",
ha="center", va="center", fontsize=10,
color="white" if value >= 10 else "black")
cbar = fig.colorbar(im, ax=ax, fraction=0.045, pad=0.04)
cbar.set_label("Weighted Prevalence (%)")
ax.set_title(
"Figure C.6.1: Physical Condition Prevalence by Psychological Distress Level\n"
"Adults Aged 16+ in England, HSE 2022",
fontweight="bold", pad=12
)
wrapped_c61 = "\n".join(textwrap.wrap(source_note_c61, width=110))
fig.text(0.12, 0.01, wrapped_c61, fontsize=8, color="#555555")
plt.tight_layout(rect=[0, 0.10, 1, 1])
plt.savefig("figure_c6_1.png", dpi=300, bbox_inches="tight")
plt.show()
print("Figure C.6.1 saved.")
# ================================================================
# Figure C.6.2 (Appendix)
# Prevalence of Probable Psychological Distress by IMD Quintile
# ================================================================
imd_ghq = []
for imd_val in range(1, 6):
subset = df_analytic[df_analytic["qimd19"] == imd_val]
imd_ghq.append({
"imd": imd_val,
"pct": wpct(subset["ghq"], 1, subset["wt_int"])
})
imd_ghq_df = pd.DataFrame(imd_ghq)
fig, ax = plt.subplots(figsize=(8.5, 5))
bars = ax.bar(
imd_ghq_df["imd"], imd_ghq_df["pct"],
color="#2C7FB8", edgecolor="white",
linewidth=0.6, alpha=0.90
)
for bar, pct in zip(bars, imd_ghq_df["pct"]):
ax.text(
bar.get_x() + bar.get_width() / 2,
bar.get_height() + 0.4,
f"{pct:.1f}%",
ha="center", va="bottom", fontsize=10
)
ax.set_xticks(range(1, 6))
ax.set_xticklabels(["Q1\nLeast deprived", "Q2", "Q3", "Q4", "Q5\nMost deprived"])
ax.set_ylim(0, 26)
ax.set_xlabel("Area Deprivation Quintile (IMD 2019)")
ax.set_ylabel("Weighted % with GHQ-12 Score ≥ 4")
ax.set_title(
"Figure C.6.2: Prevalence of Probable Psychological Distress by Area Deprivation\n"
"Adults Aged 16+ in England, HSE 2022",
fontweight="bold", pad=12
)
ax.grid(axis="y", linestyle="--", alpha=0.25)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
wrapped_c62 = "\n".join(textwrap.wrap(source_note_c62, width=110))
fig.text(0.12, 0.01, wrapped_c62, fontsize=8, color="#555555")
plt.tight_layout(rect=[0, 0.10, 1, 1])
plt.savefig("figure_c6_2.png", dpi=300, bbox_inches="tight")
plt.show()
print("Figure C.6.2 saved.")
Figure 1 saved.
Figure C.6.1 saved.
Figure C.6.2 saved.
# ================================================================
# Figure C.6.3: Geographic Analysis
# Mean Condition Count and Distress Prevalence by Region
# ================================================================
gor_labels = {
1: 'North East',
2: 'North West',
3: 'Yorkshire',
4: 'East Midlands',
5: 'West Midlands',
6: 'East of England',
7: 'London',
8: 'South East',
9: 'South West'
}
regional_data = []
for gor_val, gor_lab in gor_labels.items():
subset = df_analytic[df_analytic['GOR1'] == gor_val]
w_sub = subset['wt_int']
regional_data.append({
'region': gor_lab,
'mean_cond': wmean(subset['condlcnt'], w_sub),
'pct_ghq': wpct(subset['ghq'], 1, w_sub),
'n': len(subset)
})
reg_df = pd.DataFrame(regional_data).sort_values('mean_cond', ascending=True)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5.5))
# Panel A: Mean condition count
bars1 = ax1.barh(reg_df['region'], reg_df['mean_cond'],
color='#2C7FB8', edgecolor='white',
linewidth=0.5, alpha=0.88)
for bar, val in zip(bars1, reg_df['mean_cond']):
ax1.text(bar.get_width() + 0.01, bar.get_y() + bar.get_height()/2,
f'{val:.2f}', va='center', fontsize=9)
ax1.set_xlabel('Weighted Mean Physical Condition Count', fontsize=10)
ax1.set_title('A. Mean Physical Condition Count\nby Region', fontsize=10,
fontweight='bold')
ax1.set_xlim(0, 1.1)
ax1.spines['top'].set_visible(False)
ax1.spines['right'].set_visible(False)
ax1.grid(axis='x', linestyle='--', alpha=0.25)
# Panel B: GHQ caseness — sorted same order as Panel A
reg_df2 = reg_df.copy()
bars2 = ax2.barh(reg_df2['region'], reg_df2['pct_ghq'],
color='#D95F0E', edgecolor='white',
linewidth=0.5, alpha=0.88)
for bar, val in zip(bars2, reg_df2['pct_ghq']):
ax2.text(bar.get_width() + 0.3, bar.get_y() + bar.get_height()/2,
f'{val:.1f}%', va='center', fontsize=9)
ax2.set_xlabel('Weighted % with GHQ-12 Score ≥ 4', fontsize=10)
ax2.set_title('B. Probable Psychological Distress\nby Region', fontsize=10,
fontweight='bold')
ax2.set_xlim(0, 28)
ax2.spines['top'].set_visible(False)
ax2.spines['right'].set_visible(False)
ax2.grid(axis='x', linestyle='--', alpha=0.25)
source_note_c63 = (
"Source: NHS England (2022) Health Survey for England, 2022 [data collection]. "
"UK Data Service, SN 9469. Weighted using wt_int. "
"Regions sorted by ascending mean physical condition count."
)
wrapped_c63 = "\n".join(textwrap.wrap(source_note_c63, width=120))
fig.text(0.12, 0.01, wrapped_c63, fontsize=7.5, color="#555555")
fig.suptitle(
'Figure C.6.3: Geographic Variation in Physical Multimorbidity and Psychological Distress\n'
'Adults Aged 16+ in England, HSE 2022',
fontsize=11, fontweight='bold', y=1.02
)
plt.tight_layout()
plt.savefig('figure_c6_3.png', dpi=300, bbox_inches='tight')
plt.show()
print("Figure C.6.3 saved.")
Figure C.6.3 saved.
Appendix D: Regression Results¶
Appendix D.1: Regression Table¶
# ================================================================
# Appendix D.1: Full Regression Results
# ================================================================
def fit_nb(xvars):
X = pd.DataFrame(
sm.add_constant(r[xvars].astype(float).values, has_constant='add'),
columns=['const'] + xvars
)
result = NegativeBinomial(r['condlcnt'].values, X.values).fit(
method='bfgs', maxiter=500, disp=False
)
param_names = ['const'] + xvars + ['alpha']
result.params = pd.Series(result.params, index=param_names)
result.bse = pd.Series(result.bse, index=param_names)
result.pvalues = pd.Series(result.pvalues, index=param_names)
return result
def get_bic(mod):
return -2 * mod.llf + mod.params.shape[0] * np.log(mod.nobs)
print("Fitting models...")
m1 = fit_nb(['ghq12scr'])
m2 = fit_nb(['ghq12scr'] + demo)
m3 = fit_nb(['ghq12scr'] + demo + ses + life + geo + imd)
m4 = fit_nb(['ghq12scr'] + demo + ses + life + geo + imd + inter)
print("Done.")
# --- Table D.1a: Model Fit Statistics ---
print("\n=== Table D.1a: Model Fit Statistics ===")
print(f"{'Model':<45} {'N':>8} {'Log-lik':>12} {'AIC':>10} {'BIC':>10} {'Alpha':>10}")
print("-" * 98)
for name, mod in [
('Model 1: GHQ only', m1),
('Model 2: + Demographics', m2),
('Model 3: + Full controls', m3),
('Model 4: + GHQ x IMD interaction', m4),
]:
bic = get_bic(mod)
print(f"{name:<45} {int(mod.nobs):>8} "
f"{mod.llf:>12.3f} {mod.aic:>10.3f} "
f"{bic:>10.3f} {mod.params['alpha']:>10.4f}")
# --- Table D.1b: GHQ-12 Coefficient Progression ---
print("\n=== Table D.1b: GHQ-12 Coefficient Across Progressive Models ===")
print("Note: Unweighted regression. Weighted descriptives in Appendix C.")
print(" In Model 4, ghq12scr = GHQ slope in IMD Q1 only (reference group).")
print(" Model 3 = main average association model.")
print(f"\n{'Model':<45} {'Coef':>8} {'IRR':>8} {'SE':>8} {'95% CI':>16} {'p':>8} {'':>5}")
print("-" * 100)
for name, mod in [
('Model 1: GHQ only', m1),
('Model 2: + Demographics', m2),
('Model 3: + Full controls', m3),
('Model 4: + GHQ x IMD (ref: Q1)', m4),
]:
c = mod.params['ghq12scr']
se = mod.bse['ghq12scr']
p = mod.pvalues['ghq12scr']
irr = np.exp(c)
ci_lo = np.exp(c - 1.96*se)
ci_hi = np.exp(c + 1.96*se)
stars = '***' if p<0.001 else '**' if p<0.01 else '*' if p<0.05 else ''
print(f"{name:<45} {c:>8.4f} {irr:>8.4f} {se:>8.4f} "
f"[{ci_lo:.3f}, {ci_hi:.3f}] {p:>8.4f} {stars:>5}")
# --- Full coefficient tables for all four models ---
var_labels = (
[('GHQ-12 score', 'ghq12scr')] +
[('Female (ref: Male)', 'female')] +
[(f'Age {l} (ref: 16-24)', f'age_{i}')
for i, l in zip(range(2,8),
['25-34','35-44','45-54','55-64','65-74','75+'])] +
[(f'Ethnicity: {l} (ref: White)', f'eth_{i}')
for i, l in zip(range(2,6),
['Black','Asian','Mixed','Other'])] +
[(f'Income {l} (ref: Q1 lowest)', f'inc_{i}')
for i, l in zip(range(2,6), ['Q2','Q3','Q4','Q5'])] +
[(f'NS-SEC: {l} (ref: Managerial)', f'nssec_{i}')
for i, l in zip(range(2,4),
['Intermediate','Routine/manual'])] +
[(f'Education: {l} (ref: Degree)', f'edu_{i}')
for i, l in zip(range(2,8),
['HE below degree','A Level','O Level',
'NVQ1/CSE','Foreign/other','No qual'])] +
[(f'Smoking: {l} (ref: Never)', f'smk_{i}')
for i, l in zip(range(2,5),
['Ex-occasional','Ex-regular','Current'])] +
[(f'Alcohol: {l} (ref: Lower risk)', f'alc_{i}')
for i, l in zip([0,2,3],
['Non-drinker','Increased risk','Higher risk'])] +
[(f'Region: {l} (ref: London)', f'gor_{i}')
for i, l in zip([1,2,3,4,5,6,8,9],
['North East','North West','Yorkshire',
'East Midlands','West Midlands',
'East of England','South East','South West'])] +
[(f'IMD {l} (ref: Q1 least deprived)', f'imd_{i}')
for i, l in zip(range(2,6), ['Q2','Q3','Q4','Q5'])]
)
for mod_name, mod, extra_vars in [
('Model 1: GHQ only', m1, []),
('Model 2: + Demographics', m2, []),
('Model 3: + Full controls', m3, []),
('Model 4: + GHQ x IMD interaction', m4,
[(f'GHQ x IMD {l} (ref: Q1)', f'ghq_x_imd{i}')
for i, l in zip(range(2,6), ['Q2','Q3','Q4','Q5'])]),
]:
print(f"\n=== Full Coefficients: {mod_name} ===")
print(f"{'Variable':<45} {'Coef':>8} {'IRR':>8} {'SE':>8} {'95% CI':>16} {'p':>8} {'':>5}")
print("-" * 100)
all_labels = var_labels + extra_vars
for label, var in all_labels:
if var not in mod.params.index:
continue
c = mod.params[var]
se = mod.bse[var]
p = mod.pvalues[var]
irr = np.exp(c)
ci_lo = np.exp(c - 1.96*se)
ci_hi = np.exp(c + 1.96*se)
stars = '***' if p<0.001 else '**' if p<0.01 else '*' if p<0.05 else ''
print(f"{label:<45} {c:>8.4f} {irr:>8.4f} {se:>8.4f} "
f"[{ci_lo:.3f}, {ci_hi:.3f}] {p:>8.4f} {stars:>5}")
bic = get_bic(mod)
print(f"\n N={int(mod.nobs):,} Log-lik={mod.llf:.3f} "
f"AIC={mod.aic:.3f} BIC={bic:.3f} "
f"Alpha={mod.params['alpha']:.4f}")
Fitting models...
Done.
=== Table D.1a: Model Fit Statistics ===
Model N Log-lik AIC BIC Alpha
--------------------------------------------------------------------------------------------------
Model 1: GHQ only 4735 -5976.887 11959.773 11979.161 0.6017
Model 2: + Demographics 4735 -5748.746 11525.492 11615.971 0.3884
Model 3: + Full controls 4735 -5658.049 11404.098 11688.459 0.3041
Model 4: + GHQ x IMD interaction 4735 -5657.445 11410.889 11721.101 0.3035
=== Table D.1b: GHQ-12 Coefficient Across Progressive Models ===
Note: Unweighted regression. Weighted descriptives in Appendix C.
In Model 4, ghq12scr = GHQ slope in IMD Q1 only (reference group).
Model 3 = main average association model.
Model Coef IRR SE 95% CI p
----------------------------------------------------------------------------------------------------
Model 1: GHQ only 0.0923 1.0967 0.0059 [1.084, 1.109] 0.0000 ***
Model 2: + Demographics 0.1043 1.1100 0.0056 [1.098, 1.122] 0.0000 ***
Model 3: + Full controls 0.0921 1.0965 0.0055 [1.085, 1.108] 0.0000 ***
Model 4: + GHQ x IMD (ref: Q1) 0.0936 1.0981 0.0132 [1.070, 1.127] 0.0000 ***
=== Full Coefficients: Model 1: GHQ only ===
Variable Coef IRR SE 95% CI p
----------------------------------------------------------------------------------------------------
GHQ-12 score 0.0923 1.0967 0.0059 [1.084, 1.109] 0.0000 ***
N=4,735 Log-lik=-5976.887 AIC=11959.773 BIC=11979.161 Alpha=0.6017
=== Full Coefficients: Model 2: + Demographics ===
Variable Coef IRR SE 95% CI p
----------------------------------------------------------------------------------------------------
GHQ-12 score 0.1043 1.1100 0.0056 [1.098, 1.122] 0.0000 ***
Female (ref: Male) 0.0442 1.0451 0.0374 [0.971, 1.125] 0.2380
Age 25-34 (ref: 16-24) -0.0111 0.9890 0.1394 [0.753, 1.300] 0.9368
Age 35-44 (ref: 16-24) 0.0502 1.0515 0.1355 [0.806, 1.371] 0.7110
Age 45-54 (ref: 16-24) 0.4893 1.6311 0.1313 [1.261, 2.110] 0.0002 ***
Age 55-64 (ref: 16-24) 0.7224 2.0593 0.1286 [1.601, 2.649] 0.0000 ***
Age 65-74 (ref: 16-24) 0.9628 2.6190 0.1276 [2.040, 3.363] 0.0000 ***
Age 75+ (ref: 16-24) 1.0964 2.9933 0.1297 [2.322, 3.859] 0.0000 ***
Ethnicity: Black (ref: White) -0.2378 0.7883 0.1479 [0.590, 1.053] 0.1078
Ethnicity: Asian (ref: White) -0.3023 0.7391 0.1024 [0.605, 0.903] 0.0032 **
Ethnicity: Mixed (ref: White) -0.1601 0.8520 0.1641 [0.618, 1.175] 0.3293
Ethnicity: Other (ref: White) -0.4210 0.6564 0.2917 [0.371, 1.163] 0.1490
N=4,735 Log-lik=-5748.746 AIC=11525.492 BIC=11615.971 Alpha=0.3884
=== Full Coefficients: Model 3: + Full controls ===
Variable Coef IRR SE 95% CI p
----------------------------------------------------------------------------------------------------
GHQ-12 score 0.0921 1.0965 0.0055 [1.085, 1.108] 0.0000 ***
Female (ref: Male) 0.0154 1.0155 0.0377 [0.943, 1.093] 0.6824
Age 25-34 (ref: 16-24) -0.0368 0.9638 0.1387 [0.734, 1.265] 0.7906
Age 35-44 (ref: 16-24) 0.0507 1.0520 0.1351 [0.807, 1.371] 0.7077
Age 45-54 (ref: 16-24) 0.4547 1.5756 0.1309 [1.219, 2.037] 0.0005 ***
Age 55-64 (ref: 16-24) 0.6860 1.9857 0.1281 [1.545, 2.552] 0.0000 ***
Age 65-74 (ref: 16-24) 0.8958 2.4492 0.1284 [1.904, 3.150] 0.0000 ***
Age 75+ (ref: 16-24) 1.0066 2.7362 0.1326 [2.110, 3.548] 0.0000 ***
Ethnicity: Black (ref: White) -0.2758 0.7590 0.1479 [0.568, 1.014] 0.0622
Ethnicity: Asian (ref: White) -0.3069 0.7357 0.1049 [0.599, 0.904] 0.0034 **
Ethnicity: Mixed (ref: White) -0.1664 0.8467 0.1615 [0.617, 1.162] 0.3027
Ethnicity: Other (ref: White) -0.4417 0.6429 0.2893 [0.365, 1.134] 0.1268
Income Q2 (ref: Q1 lowest) 0.1112 1.1176 0.0557 [1.002, 1.246] 0.0458 *
Income Q3 (ref: Q1 lowest) -0.0119 0.9882 0.0608 [0.877, 1.113] 0.8455
Income Q4 (ref: Q1 lowest) -0.1217 0.8854 0.0647 [0.780, 1.005] 0.0598
Income Q5 (ref: Q1 lowest) -0.1595 0.8526 0.0713 [0.741, 0.980] 0.0252 *
NS-SEC: Intermediate (ref: Managerial) 0.0988 1.1039 0.0510 [0.999, 1.220] 0.0527
NS-SEC: Routine/manual (ref: Managerial) 0.1361 1.1458 0.0513 [1.036, 1.267] 0.0081 **
Education: HE below degree (ref: Degree) -0.0233 0.9770 0.0608 [0.867, 1.101] 0.7020
Education: A Level (ref: Degree) -0.1145 0.8918 0.0617 [0.790, 1.007] 0.0636
Education: O Level (ref: Degree) -0.0683 0.9339 0.0584 [0.833, 1.047] 0.2417
Education: NVQ1/CSE (ref: Degree) 0.0013 1.0013 0.1148 [0.800, 1.254] 0.9906
Education: Foreign/other (ref: Degree) -0.1818 0.8338 0.1880 [0.577, 1.205] 0.3334
Education: No qual (ref: Degree) -0.1256 0.8820 0.0668 [0.774, 1.005] 0.0601
Smoking: Ex-occasional (ref: Never) 0.0764 1.0794 0.0700 [0.941, 1.238] 0.2749
Smoking: Ex-regular (ref: Never) 0.1967 1.2173 0.0427 [1.120, 1.324] 0.0000 ***
Smoking: Current (ref: Never) 0.2175 1.2430 0.0600 [1.105, 1.398] 0.0003 ***
Alcohol: Non-drinker (ref: Lower risk) 0.1763 1.1928 0.0510 [1.079, 1.318] 0.0005 ***
Alcohol: Increased risk (ref: Lower risk) -0.1820 0.8336 0.0498 [0.756, 0.919] 0.0003 ***
Alcohol: Higher risk (ref: Lower risk) 0.0854 1.0892 0.0780 [0.935, 1.269] 0.2734
Region: North East (ref: London) 0.0168 1.0170 0.0873 [0.857, 1.207] 0.8473
Region: North West (ref: London) 0.1474 1.1588 0.0852 [0.980, 1.370] 0.0838
Region: Yorkshire (ref: London) -0.0342 0.9664 0.0879 [0.813, 1.148] 0.6973
Region: East Midlands (ref: London) 0.1169 1.1240 0.0908 [0.941, 1.343] 0.1982
Region: West Midlands (ref: London) -0.1211 0.8860 0.0939 [0.737, 1.065] 0.1973
Region: East of England (ref: London) 0.2382 1.2690 0.0842 [1.076, 1.497] 0.0046 **
Region: South East (ref: London) -0.0053 0.9948 0.0814 [0.848, 1.167] 0.9485
Region: South West (ref: London) 0.0141 1.0142 0.0855 [0.858, 1.199] 0.8690
IMD Q2 (ref: Q1 least deprived) 0.1384 1.1484 0.0550 [1.031, 1.279] 0.0119 *
IMD Q3 (ref: Q1 least deprived) 0.0650 1.0671 0.0587 [0.951, 1.197] 0.2684
IMD Q4 (ref: Q1 least deprived) 0.1342 1.1436 0.0603 [1.016, 1.287] 0.0261 *
IMD Q5 (ref: Q1 least deprived) 0.2253 1.2526 0.0638 [1.105, 1.419] 0.0004 ***
N=4,735 Log-lik=-5658.049 AIC=11404.098 BIC=11688.459 Alpha=0.3041
=== Full Coefficients: Model 4: + GHQ x IMD interaction ===
Variable Coef IRR SE 95% CI p
----------------------------------------------------------------------------------------------------
GHQ-12 score 0.0936 1.0981 0.0132 [1.070, 1.127] 0.0000 ***
Female (ref: Male) 0.0159 1.0160 0.0377 [0.944, 1.094] 0.6734
Age 25-34 (ref: 16-24) -0.0314 0.9691 0.1389 [0.738, 1.272] 0.8213
Age 35-44 (ref: 16-24) 0.0554 1.0570 0.1352 [0.811, 1.378] 0.6821
Age 45-54 (ref: 16-24) 0.4607 1.5851 0.1311 [1.226, 2.049] 0.0004 ***
Age 55-64 (ref: 16-24) 0.6927 1.9991 0.1284 [1.554, 2.571] 0.0000 ***
Age 65-74 (ref: 16-24) 0.9005 2.4609 0.1286 [1.913, 3.166] 0.0000 ***
Age 75+ (ref: 16-24) 1.0097 2.7448 0.1327 [2.116, 3.560] 0.0000 ***
Ethnicity: Black (ref: White) -0.2743 0.7601 0.1478 [0.569, 1.016] 0.0635
Ethnicity: Asian (ref: White) -0.3103 0.7333 0.1050 [0.597, 0.901] 0.0031 **
Ethnicity: Mixed (ref: White) -0.1694 0.8441 0.1615 [0.615, 1.158] 0.2941
Ethnicity: Other (ref: White) -0.4399 0.6441 0.2892 [0.365, 1.135] 0.1282
Income Q2 (ref: Q1 lowest) 0.1070 1.1129 0.0558 [0.998, 1.242] 0.0552
Income Q3 (ref: Q1 lowest) -0.0148 0.9853 0.0609 [0.874, 1.110] 0.8079
Income Q4 (ref: Q1 lowest) -0.1246 0.8828 0.0647 [0.778, 1.002] 0.0542
Income Q5 (ref: Q1 lowest) -0.1629 0.8497 0.0713 [0.739, 0.977] 0.0224 *
NS-SEC: Intermediate (ref: Managerial) 0.0986 1.1036 0.0511 [0.998, 1.220] 0.0536
NS-SEC: Routine/manual (ref: Managerial) 0.1351 1.1447 0.0514 [1.035, 1.266] 0.0086 **
Education: HE below degree (ref: Degree) -0.0239 0.9763 0.0609 [0.867, 1.100] 0.6941
Education: A Level (ref: Degree) -0.1150 0.8913 0.0618 [0.790, 1.006] 0.0628
Education: O Level (ref: Degree) -0.0686 0.9337 0.0584 [0.833, 1.047] 0.2403
Education: NVQ1/CSE (ref: Degree) 0.0024 1.0024 0.1147 [0.801, 1.255] 0.9834
Education: Foreign/other (ref: Degree) -0.1765 0.8382 0.1880 [0.580, 1.212] 0.3480
Education: No qual (ref: Degree) -0.1256 0.8820 0.0669 [0.774, 1.006] 0.0604
Smoking: Ex-occasional (ref: Never) 0.0783 1.0814 0.0701 [0.943, 1.241] 0.2641
Smoking: Ex-regular (ref: Never) 0.1982 1.2192 0.0427 [1.121, 1.326] 0.0000 ***
Smoking: Current (ref: Never) 0.2179 1.2434 0.0601 [1.105, 1.399] 0.0003 ***
Alcohol: Non-drinker (ref: Lower risk) 0.1781 1.1950 0.0511 [1.081, 1.321] 0.0005 ***
Alcohol: Increased risk (ref: Lower risk) -0.1817 0.8338 0.0498 [0.756, 0.919] 0.0003 ***
Alcohol: Higher risk (ref: Lower risk) 0.0875 1.0914 0.0780 [0.937, 1.272] 0.2620
Region: North East (ref: London) 0.0187 1.0189 0.0873 [0.859, 1.209] 0.8304
Region: North West (ref: London) 0.1485 1.1600 0.0853 [0.981, 1.371] 0.0817
Region: Yorkshire (ref: London) -0.0346 0.9660 0.0879 [0.813, 1.148] 0.6938
Region: East Midlands (ref: London) 0.1150 1.1219 0.0909 [0.939, 1.341] 0.2060
Region: West Midlands (ref: London) -0.1199 0.8870 0.0939 [0.738, 1.066] 0.2017
Region: East of England (ref: London) 0.2401 1.2714 0.0842 [1.078, 1.500] 0.0044 **
Region: South East (ref: London) -0.0049 0.9951 0.0814 [0.848, 1.167] 0.9518
Region: South West (ref: London) 0.0146 1.0147 0.0856 [0.858, 1.200] 0.8643
IMD Q2 (ref: Q1 least deprived) 0.1444 1.1553 0.0646 [1.018, 1.311] 0.0255 *
IMD Q3 (ref: Q1 least deprived) 0.0443 1.0453 0.0691 [0.913, 1.197] 0.5216
IMD Q4 (ref: Q1 least deprived) 0.1387 1.1488 0.0710 [1.000, 1.320] 0.0507
IMD Q5 (ref: Q1 least deprived) 0.2500 1.2840 0.0749 [1.109, 1.487] 0.0008 ***
GHQ x IMD Q2 (ref: Q1) -0.0032 0.9968 0.0177 [0.963, 1.032] 0.8552
GHQ x IMD Q3 (ref: Q1) 0.0086 1.0086 0.0178 [0.974, 1.044] 0.6301
GHQ x IMD Q4 (ref: Q1) -0.0024 0.9976 0.0179 [0.963, 1.033] 0.8941
GHQ x IMD Q5 (ref: Q1) -0.0090 0.9910 0.0172 [0.958, 1.025] 0.6009
N=4,735 Log-lik=-5657.445 AIC=11410.889 BIC=11721.101 Alpha=0.3035
Appendix D.2: Interaction Terms and Joint Wald Test¶
# ================================================================
# Appendix D.2: Interaction Terms and Tests of Effect Modification
# ================================================================
# --- Table D.2a: GHQ x IMD Interaction Coefficients ---
print("=== Table D.2a: GHQ x IMD Interaction Coefficients (Model 4) ===")
print("Note: ghq12scr = GHQ-12 slope in IMD Q1 (reference group).")
print(" Interaction terms = additional GHQ slope in each quintile vs Q1.")
print(f"\n{'Variable':<40} {'Coef':>8} {'IRR':>8} {'SE':>8} {'95% CI':>16} {'p':>8} {'':>5}")
print("-" * 90)
interact_vars = [
('GHQ-12 score (ref: IMD Q1)', 'ghq12scr'),
('GHQ x IMD Q2 (vs Q1)', 'ghq_x_imd2'),
('GHQ x IMD Q3 (vs Q1)', 'ghq_x_imd3'),
('GHQ x IMD Q4 (vs Q1)', 'ghq_x_imd4'),
('GHQ x IMD Q5 (vs Q1)', 'ghq_x_imd5'),
]
for label, var in interact_vars:
c = m4.params[var]
se = m4.bse[var]
p = m4.pvalues[var]
irr = np.exp(c)
ci_lo = np.exp(c - 1.96*se)
ci_hi = np.exp(c + 1.96*se)
stars = '***' if p<0.001 else '**' if p<0.01 else '*' if p<0.05 else ''
print(f"{label:<40} {c:>8.4f} {irr:>8.4f} {se:>8.4f} "
f"[{ci_lo:.3f}, {ci_hi:.3f}] {p:>8.4f} {stars:>5}")
# --- Table D.2b: Tests of Joint Significance ---
print("\n=== Table D.2b: Tests of Joint Significance of Interaction Terms ===")
print("H0: All GHQ x IMD interaction terms jointly equal zero")
print(" (i.e. the GHQ-12 effect does not vary across deprivation quintiles)")
# Likelihood Ratio Test
lr_stat = 2 * (m4.llf - m3.llf)
df_diff = len(inter)
lr_p = chi2_dist.sf(lr_stat, df_diff)
print(f"\nLikelihood Ratio Test (Model 4 vs Model 3):")
print(f" LR statistic: {lr_stat:.4f}")
print(f" Degrees of freedom:{df_diff}")
print(f" p-value: {lr_p:.4f}")
# Wald Test
inter_indices = [list(m4.params.index).index(v) for v in inter]
cov = np.array([[m4.cov_params()[i, j]
for j in inter_indices]
for i in inter_indices])
coefs = m4.params[inter].values
wald_stat = coefs @ np.linalg.inv(cov) @ coefs
wald_p = chi2_dist.sf(wald_stat, df_diff)
print(f"\nWald Test:")
print(f" Wald statistic: {wald_stat:.4f}")
print(f" Degrees of freedom:{df_diff}")
print(f" p-value: {wald_p:.4f}")
# Model comparison
print(f"\nModel Comparison (AIC):")
print(f" Model 3 (no interaction): AIC = {m3.aic:.3f}")
print(f" Model 4 (with interaction): AIC = {m4.aic:.3f}")
print(f" Delta AIC (M4 - M3): {m4.aic - m3.aic:.3f}")
print("\nNote: Interpret LR and Wald test results in conjunction.")
print(" See Appendix D.2 narrative for discussion.")
=== Table D.2a: GHQ x IMD Interaction Coefficients (Model 4) ===
Note: ghq12scr = GHQ-12 slope in IMD Q1 (reference group).
Interaction terms = additional GHQ slope in each quintile vs Q1.
Variable Coef IRR SE 95% CI p
------------------------------------------------------------------------------------------
GHQ-12 score (ref: IMD Q1) 0.0936 1.0981 0.0132 [1.070, 1.127] 0.0000 ***
GHQ x IMD Q2 (vs Q1) -0.0032 0.9968 0.0177 [0.963, 1.032] 0.8552
GHQ x IMD Q3 (vs Q1) 0.0086 1.0086 0.0178 [0.974, 1.044] 0.6301
GHQ x IMD Q4 (vs Q1) -0.0024 0.9976 0.0179 [0.963, 1.033] 0.8941
GHQ x IMD Q5 (vs Q1) -0.0090 0.9910 0.0172 [0.958, 1.025] 0.6009
=== Table D.2b: Tests of Joint Significance of Interaction Terms ===
H0: All GHQ x IMD interaction terms jointly equal zero
(i.e. the GHQ-12 effect does not vary across deprivation quintiles)
Likelihood Ratio Test (Model 4 vs Model 3):
LR statistic: 1.2090
Degrees of freedom:4
p-value: 0.8766
Wald Test:
Wald statistic: 1.2085
Degrees of freedom:4
p-value: 0.8767
Model Comparison (AIC):
Model 3 (no interaction): AIC = 11404.098
Model 4 (with interaction): AIC = 11410.889
Delta AIC (M4 - M3): 6.791
Note: Interpret LR and Wald test results in conjunction.
See Appendix D.2 narrative for discussion.
Appendix E: Marginal Effects¶
Appendix E.1: Predicted Condition Count Table¶
# ================================================================
# Appendix E.1: Predicted Condition Count Table
# ================================================================
coef_vars_m3 = [v for v in m3.params.index if v != 'alpha']
def avg_marginal_pred(ghq_val, imd_q, model):
r_temp = r.copy()
r_temp['ghq12scr'] = float(ghq_val)
for i in range(2, 6):
r_temp[f'imd_{i}'] = (1 if i == imd_q else 0)
X_temp = np.column_stack([
np.ones(len(r_temp)) if v == 'const'
else r_temp[v].values
for v in coef_vars_m3
])
coefs = model.params[coef_vars_m3].values
lp = X_temp @ coefs
return np.exp(lp).mean()
ghq_values = [0, 2, 4, 6, 8, 10, 12]
pred_matrix = {}
for ghq_val in ghq_values:
row = {}
for q in range(1, 6):
row[q] = avg_marginal_pred(ghq_val, q, m3)
pred_matrix[ghq_val] = row
# Table E.1a: Full — Q1, Q3, Q5 only
print("=== Table E.1a: Average Marginal Predicted Condition Count ===")
print("Model 3. All other covariates at individual observed values.")
print(f"\n{'GHQ Score':<12} {'IMD Q1':>12} {'IMD Q3':>12} {'IMD Q5':>12}")
print("-" * 50)
for ghq_val in ghq_values:
q1 = pred_matrix[ghq_val][1]
q3 = pred_matrix[ghq_val][3]
q5 = pred_matrix[ghq_val][5]
print(f"{ghq_val:<12} {q1:>12.3f} {q3:>12.3f} {q5:>12.3f}")
print(f"\n{'Absolute gap (GHQ=12 vs GHQ=0)':<35}", end='')
for q in [1, 3, 5]:
gap = pred_matrix[12][q] - pred_matrix[0][q]
print(f"{gap:>12.3f}", end='')
print()
# Table E.1b: Condensed — GHQ 0, 4, 8, 12 only
print("\n=== Table E.1b: Condensed Predicted Condition Count ===")
print("GHQ scores 0, 4, 8, 12 and IMD Q1, Q3, Q5 only.")
print(f"\n{'GHQ Score':<12} {'IMD Q1':>12} {'IMD Q3':>12} {'IMD Q5':>12}")
print("-" * 50)
for ghq_val in [0, 4, 8, 12]:
q1 = pred_matrix[ghq_val][1]
q3 = pred_matrix[ghq_val][3]
q5 = pred_matrix[ghq_val][5]
print(f"{ghq_val:<12} {q1:>12.3f} {q3:>12.3f} {q5:>12.3f}")
=== Table E.1a: Average Marginal Predicted Condition Count === Model 3. All other covariates at individual observed values. GHQ Score IMD Q1 IMD Q3 IMD Q5 -------------------------------------------------- 0 0.643 0.686 0.805 2 0.773 0.824 0.968 4 0.929 0.991 1.164 6 1.117 1.192 1.399 8 1.343 1.433 1.682 10 1.615 1.723 2.023 12 1.941 2.072 2.432 Absolute gap (GHQ=12 vs GHQ=0) 1.299 1.386 1.627 === Table E.1b: Condensed Predicted Condition Count === GHQ scores 0, 4, 8, 12 and IMD Q1, Q3, Q5 only. GHQ Score IMD Q1 IMD Q3 IMD Q5 -------------------------------------------------- 0 0.643 0.686 0.805 4 0.929 0.991 1.164 8 1.343 1.433 1.682 12 1.941 2.072 2.432
Appendix E.2: Marginal Effects Figure¶
# ================================================================
# Appendix E.2: Marginal Effects Figure
# ================================================================
ghq_fine = list(range(0, 13))
plot_imd = [1, 3, 5]
imd_colors = ['#1a9641', '#fdae61', '#d7191c']
imd_labels = ['Q1 (Least deprived)', 'Q3 (Middle)', 'Q5 (Most deprived)']
fig, ax = plt.subplots(figsize=(10, 6))
for q, col, lab in zip(plot_imd, imd_colors, imd_labels):
preds = [avg_marginal_pred(g, q, m3) for g in ghq_fine]
ax.plot(ghq_fine, preds,
color=col, linewidth=2.5,
marker='o', markersize=5,
label=lab)
ax.axvline(x=4, color='#999999', linestyle=':', linewidth=1.2, alpha=0.6)
ax.text(4.15, 0.75, 'Caseness\nthreshold', fontsize=8.5, color='#999999')
ax.set_xlabel('GHQ-12 Psychological Distress Score\n(0 = no distress, 12 = severe distress)',
fontsize=11, labelpad=8)
ax.set_ylabel('Average Predicted Physical Condition Count',
fontsize=11, labelpad=8)
ax.set_title('Figure E.2: Adjusted Physical Condition Count\n'
'by GHQ-12 Score and Area Deprivation, HSE 2022',
fontsize=12, fontweight='bold', pad=12)
ax.set_xticks(ghq_fine)
ax.legend(title='IMD Deprivation Quintile', fontsize=10,
title_fontsize=10, loc='upper left', framealpha=0.9)
ax.grid(alpha=0.25, linestyle='--')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
source_note_e2 = (
"Source: NHS England (2022) Health Survey for England, 2022 [data collection]. "
"UK Data Service, SN 9469. Average marginal predictions from Model 3 "
"(negative binomial regression, unweighted, N=4,735). "
"All other covariates retained at observed individual values."
)
wrapped_e2 = "\n".join(textwrap.wrap(source_note_e2, width=110))
fig.text(0.12, 0.01, wrapped_e2, fontsize=7.5, color="#555555")
plt.tight_layout(rect=[0, 0.10, 1, 1])
plt.savefig('figure_e2_marginal_effects.png', dpi=300, bbox_inches='tight')
plt.show()
print("Figure E.2 saved.")
Figure E.2 saved.
for bmi_v in ['bmival', 'bmivg3', 'bmisrg3']:
df[bmi_v] = clean(df[bmi_v])
valid = df[bmi_v].notna().sum()
pct = valid/len(df)*100
joint = df.dropna(subset=reg_vars+[bmi_v]).shape[0]
print(f'{bmi_v}: valid={valid:,} ({pct:.1f}%), joint sample={joint:,}')
bmival: valid=4,513 (58.4%), joint sample=3,293 bmivg3: valid=4,512 (58.4%), joint sample=3,293 bmisrg3: valid=2,469 (31.9%), joint sample=1,075
Appendix F: Robustness Checks¶
# ================================================================
# Appendix F: Robustness Checks
# ================================================================
from statsmodels.discrete.discrete_model import Poisson
from statsmodels.regression.linear_model import OLS
from statsmodels.discrete.discrete_model import Logit
from scipy.stats import chi2 as chi2_dist
# ================================================================
# F.1: Poisson vs Negative Binomial — LR Test
# ================================================================
print("=" * 70)
print("F.1: Poisson vs Negative Binomial — Likelihood Ratio Test")
print("=" * 70)
# Fit Poisson Model 3
def fit_poisson(xvars):
X = pd.DataFrame(
sm.add_constant(r[xvars].astype(float).values, has_constant='add'),
columns=['const'] + xvars
)
result = Poisson(r['condlcnt'].values, X.values).fit(
method='bfgs', maxiter=500, disp=False
)
param_names = ['const'] + xvars + []
result.params = pd.Series(result.params, index=['const'] + xvars)
result.bse = pd.Series(result.bse, index=['const'] + xvars)
result.pvalues = pd.Series(result.pvalues, index=['const'] + xvars)
return result
vars_m3 = ['ghq12scr'] + demo + ses + life + geo + imd
p3 = fit_poisson(vars_m3)
print(f"\nPoisson Model 3:")
print(f" Log-likelihood: {p3.llf:.3f}")
print(f" AIC: {p3.aic:.3f}")
print(f" GHQ-12 IRR: {np.exp(p3.params['ghq12scr']):.4f}")
print(f" GHQ-12 p: {p3.pvalues['ghq12scr']:.4f}")
print(f"\nNegative Binomial Model 3:")
print(f" Log-likelihood: {m3.llf:.3f}")
print(f" AIC: {m3.aic:.3f}")
print(f" GHQ-12 IRR: {np.exp(m3.params['ghq12scr']):.4f}")
print(f" GHQ-12 p: {m3.pvalues['ghq12scr']:.4f}")
# LR test: NB vs Poisson
# NB nests Poisson when alpha=0
# LR stat = 2*(llf_NB - llf_Poisson), df=1
lr_stat_f1 = 2 * (m3.llf - p3.llf)
lr_p_f1 = chi2_dist.sf(lr_stat_f1, 1) / 2 # one-sided
print(f"\nLikelihood Ratio Test (NB vs Poisson):")
print(f" LR statistic: {lr_stat_f1:.4f}")
print(f" p-value (one-sided): {lr_p_f1:.6f}")
print(f" Alpha (NB dispersion): {m3.params['alpha']:.4f}")
print(f"\nConclusion: {'Reject Poisson' if lr_p_f1 < 0.05 else 'Cannot reject Poisson'} in favour of NB")
# ================================================================
# F.2: OLS Robustness Check
# ================================================================
print("\n" + "=" * 70)
print("F.2: OLS Robustness Check")
print("=" * 70)
X_ols = sm.add_constant(
r[vars_m3].astype(float).values,
has_constant='add'
)
ols_result = OLS(r['condlcnt'].values.astype(float), X_ols).fit()
# Extract GHQ coefficient
ghq_idx = ['const'] + vars_m3
ghq_pos = ghq_idx.index('ghq12scr')
ols_coef = ols_result.params[ghq_pos]
ols_se = ols_result.bse[ghq_pos]
ols_p = ols_result.pvalues[ghq_pos]
ols_ci = ols_result.conf_int()[ghq_pos]
print(f"\nOLS Model (outcome: condlcnt, same controls as Model 3):")
print(f" GHQ-12 coefficient: {ols_coef:.4f}")
print(f" SE: {ols_se:.4f}")
print(f" 95% CI: [{ols_ci[0]:.4f}, {ols_ci[1]:.4f}]")
print(f" p-value: {ols_p:.4f}")
print(f" R-squared: {ols_result.rsquared:.4f}")
print(f" N: {int(ols_result.nobs):,}")
print(f"\nComparison with NB Model 3:")
print(f" NB IRR: {np.exp(m3.params['ghq12scr']):.4f}")
print(f" OLS coefficient: {ols_coef:.4f}")
print(f" Interpretation: A one-unit increase in GHQ-12 is associated with")
print(f" {ols_coef:.4f} additional conditions (OLS) vs {np.exp(m3.params['ghq12scr']):.4f}x IRR (NB)")
# ================================================================
# F.3: Logistic Regression — Binary Multimorbidity
# ================================================================
print("\n" + "=" * 70)
print("F.3: Logistic Regression — Binary Multimorbidity Outcome")
print("=" * 70)
def fit_logit(xvars):
X = pd.DataFrame(
sm.add_constant(r[xvars].astype(float).values, has_constant='add'),
columns=['const'] + xvars
)
result = Logit(r['multimorbid'].values.astype(float), X.values).fit(
method='bfgs', maxiter=500, disp=False
)
result.params = pd.Series(result.params, index=['const'] + xvars)
result.bse = pd.Series(result.bse, index=['const'] + xvars)
result.pvalues = pd.Series(result.pvalues, index=['const'] + xvars)
return result
logit_m3 = fit_logit(vars_m3)
c_l = logit_m3.params['ghq12scr']
se_l = logit_m3.bse['ghq12scr']
p_l = logit_m3.pvalues['ghq12scr']
or_l = np.exp(c_l)
ci_lo_l = np.exp(c_l - 1.96*se_l)
ci_hi_l = np.exp(c_l + 1.96*se_l)
print(f"\nLogistic Regression (outcome: multimorbid = 1 if condlcnt >= 2):")
print(f" GHQ-12 coefficient: {c_l:.4f}")
print(f" Odds Ratio: {or_l:.4f}")
print(f" 95% CI: [{ci_lo_l:.4f}, {ci_hi_l:.4f}]")
print(f" p-value: {p_l:.4f}")
print(f" N: {int(logit_m3.nobs):,}")
print(f"\nComparison with NB Model 3:")
print(f" NB IRR (condlcnt): {np.exp(m3.params['ghq12scr']):.4f}")
print(f" Logit OR (multimorbid): {or_l:.4f}")
print(f" Both statistically significant: {'Yes' if p_l < 0.001 else 'No'}")
# ================================================================
# F.4: BMI Sensitivity Analysis (nurse-measured bmival, N=3,293)
# ================================================================
print("\n" + "=" * 70)
print("F.4: BMI Sensitivity Analysis (nurse-measured bmival)")
print("=" * 70)
# Clean bmival
df_analytic['bmival_clean'] = df_analytic['bmival'].copy()
df_analytic.loc[df_analytic['bmival_clean'] < 0, 'bmival_clean'] = np.nan
# BMI subsample
bmi_vars = vars_m3 + ['bmival_clean']
r_bmi = df_analytic.dropna(subset=
['ghq12scr','condlcnt','qimd19','Sex','ag16g10',
'origin2','eqv5','nssec3','topqual3',
'cigst1_19','totalwug2_22','GOR1','bmival_clean']
).copy()
# Rebuild dummies for BMI subsample
for col in demo + ses + life + geo + imd:
if col not in r_bmi.columns:
r_bmi[col] = r[col]
print(f"\nBMI subsample N: {len(r_bmi):,}")
print(f"Main sample N: {len(r):,}")
print(f"Sample reduction: {len(r)-len(r_bmi):,} ({(len(r)-len(r_bmi))/len(r)*100:.1f}%)")
# Fit NB without BMI on subsample
def fit_nb_sub(xvars, data):
X = pd.DataFrame(
sm.add_constant(data[xvars].astype(float).values, has_constant='add'),
columns=['const'] + xvars
)
result = NegativeBinomial(data['condlcnt'].values, X.values).fit(
method='bfgs', maxiter=500, disp=False
)
result.params = pd.Series(result.params, index=['const'] + xvars + ['alpha'])
result.bse = pd.Series(result.bse, index=['const'] + xvars + ['alpha'])
result.pvalues = pd.Series(result.pvalues, index=['const'] + xvars + ['alpha'])
return result
m3_bmi_sub = fit_nb_sub(vars_m3, r_bmi)
m3_bmi_full = fit_nb_sub(vars_m3 + ['bmival_clean'], r_bmi)
print(f"\nNB without BMI (subsample N={len(r_bmi):,}):")
print(f" GHQ-12 IRR: {np.exp(m3_bmi_sub.params['ghq12scr']):.4f}")
print(f" p-value: {m3_bmi_sub.pvalues['ghq12scr']:.4f}")
print(f"\nNB with BMI (subsample N={len(r_bmi):,}):")
print(f" GHQ-12 IRR: {np.exp(m3_bmi_full.params['ghq12scr']):.4f}")
print(f" p-value: {m3_bmi_full.pvalues['ghq12scr']:.4f}")
print(f" BMI IRR: {np.exp(m3_bmi_full.params['bmival_clean']):.4f}")
print(f" BMI p: {m3_bmi_full.pvalues['bmival_clean']:.4f}")
print(f"\nComparison with main model (N={len(r):,}):")
print(f" Main model GHQ-12 IRR: {np.exp(m3.params['ghq12scr']):.4f}")
print(f" Subsample without BMI IRR: {np.exp(m3_bmi_sub.params['ghq12scr']):.4f}")
print(f" Subsample with BMI IRR: {np.exp(m3_bmi_full.params['ghq12scr']):.4f}")
====================================================================== F.1: Poisson vs Negative Binomial — Likelihood Ratio Test ====================================================================== Poisson Model 3: Log-likelihood: -5719.387 AIC: 11524.775 GHQ-12 IRR: 1.0920 GHQ-12 p: 0.0000 Negative Binomial Model 3: Log-likelihood: -5658.049 AIC: 11404.098 GHQ-12 IRR: 1.0965 GHQ-12 p: 0.0000 Likelihood Ratio Test (NB vs Poisson): LR statistic: 122.6766 p-value (one-sided): 0.000000 Alpha (NB dispersion): 0.3041 Conclusion: Reject Poisson in favour of NB ====================================================================== F.2: OLS Robustness Check ====================================================================== OLS Model (outcome: condlcnt, same controls as Model 3): GHQ-12 coefficient: 0.1024 SE: 0.0055 95% CI: [0.0917, 0.1132] p-value: 0.0000 R-squared: 0.1837 N: 4,735 Comparison with NB Model 3: NB IRR: 1.0965 OLS coefficient: 0.1024 Interpretation: A one-unit increase in GHQ-12 is associated with 0.1024 additional conditions (OLS) vs 1.0965x IRR (NB) ====================================================================== F.3: Logistic Regression — Binary Multimorbidity Outcome ====================================================================== Logistic Regression (outcome: multimorbid = 1 if condlcnt >= 2): GHQ-12 coefficient: 0.1627 Odds Ratio: 1.1767 95% CI: [1.1491, 1.2050] p-value: 0.0000 N: 4,735 Comparison with NB Model 3: NB IRR (condlcnt): 1.0965 Logit OR (multimorbid): 1.1767 Both statistically significant: Yes ====================================================================== F.4: BMI Sensitivity Analysis (nurse-measured bmival) ====================================================================== BMI subsample N: 3,293 Main sample N: 4,735 Sample reduction: 1,442 (30.5%) NB without BMI (subsample N=3,293): GHQ-12 IRR: 1.0929 p-value: 0.0000 NB with BMI (subsample N=3,293): GHQ-12 IRR: 1.0867 p-value: 0.0000 BMI IRR: 1.0344 BMI p: 0.0000 Comparison with main model (N=4,735): Main model GHQ-12 IRR: 1.0965 Subsample without BMI IRR: 1.0929 Subsample with BMI IRR: 1.0867