Method study - every number simulated by design

What this meansis the best 95% interval coverage the estimator reaches at any sample size tested. The estimates are nearly unbiased; the uncertainty reported around them is too narrow, which reads as confidence.

Source artifact: results/study01_recovery.csv

What SEM cannot tell you about safety risk

A from-scratch numpy estimator, then simulation studies covering coefficient recovery, interval coverage, sample size, rare-event calibration, misspecification and equivalence. Ground truth is known by construction: behaviour is measured, not assumed. No real injury data here.

What this does not establishNo claim about real safety data. Every observation on this page is simulated from a model this code wrote, so the results bound what the method can recover under ideal conditions. They do not show that a real risk score is right or wrong.

Simulation studies4
Result tables19
Coefficients recoveredYes
Real-world claimsNone

Drag the sample size

Every estimate below was produced by fitting data simulated from known path weights, 300 replications per cell. Drag the sample size. The wide band is where estimates actually land; the thin bar is the interval the estimator reports. When the thin bar is shorter, the reported confidence is false. API reference →

Where estimates land (mean ± 1.96 empirical SD)Interval the estimator reports (± 1.96 analytic SE)True weight

All four paths at this n select a row

-

True weight
-
Mean estimate
-
Empirical SD
-
Mean analytic SE
-
SE ratio
-
95% coverage
-

Tick marks the nominal 95%.

The selected path, at every sample size tested

Source artifact: results/study01_recovery.csv

Why this exists

A formula that fits perfectly, recovers its own coefficients, and passes every conventional cutoff can still point the wrong way down its own arrows.

A particular formula circulates in practitioner writing on safety analytics: risk as a weighted sum of unsafe acts, operational stress, system condition and safety response capability, with weights around 0.45, 0.30, -0.25 and -0.20, described as a structural equation model with machine-constructed latent variables.

Read on

This repository builds the estimator that would be needed to produce that formula, from scratch in numpy, and then tests what such an estimator can support. Most of the answer is negative, and the negative results are the contribution.

The clearest one is here. Fitting the same data with the causal arrow reversed returns an identical coefficient (0.493 both ways), an identical chi-square, an identical CFI and an identical SRMR. Both models meet every conventional cutoff. Goodness of fit cannot tell you which way causation runs, because it never could.

Everything here is simulated

Read on

Every number on this page comes from data generated by a model whose parameters were fixed in advance. That is what a simulation study is for: ground truth is known, so estimator behaviour can be measured against it rather than assumed.

No number on this page is a claim about any real workplace, any real programme, or any real injury. Nothing in this repository touches real injury data at any point.

What this does and does not show

What this shows

  • Point estimates are nearly unbiased at every sample size tested, from n=100 to n=5,000.
  • Interval coverage sits at 76% to 86% against a nominal 95%, and does not improve with n.
  • Telling the two negative coefficients apart at 80% power needs a sample of 6,884.
  • Fitting the same data with the causal arrow reversed returns an identical coefficient (0.493 both ways) and identical fit statistics.

What this does not show

  • Simulation establishes estimator properties, not empirical facts. These studies say nothing about whether any particular safety programme works, and they are not evidence about any real site.
  • The generating models are the author's, chosen to resemble the structure the circulating formula implies. A different generating model would give different numbers, though the identification results do not depend on the parameterisation.
  • Replication counts are modest in places and each table records its own. The bootstrap study runs 40 replications, which is enough to show the direction of the coverage gap and not enough to pin its size.
  • Nothing here has been peer reviewed.

The practical conclusion is narrow and worth stating plainly: illustrative path weights of the kind that circulate in practitioner writing cannot be read causally, cannot be transferred between sites, and cannot be validated by goodness of fit.

Recovery, and the intervals around it

Start with the friendliest possible test.

Why
Generate data from a known model, then estimate it. If the estimator cannot recover coefficients it generated itself, nothing downstream is worth reading.

Coverage sits at 76% to 86% against a nominal 95%, at every sample size.

Source artifact: results/study01_recovery.csv

AUC holds near 0.69 at every base rate; alerts per true event climb to 385 at the rarest.

Source artifact: results/study02_discrimination.csv

Interval coverage against sample size

0.70.750.80.850.90.951nominal 95%n=100n=200n=500n=1,000n=2,000n=5,000UnsafeActsOperationalStressSystemConditionSafetyResponse
Coverage of the nominal 95% confidence interval, by sample size, for each of the four path coefficients.
Why
Coverage should sit on the dashed line. It sits between 76% and 86% instead, and it does not improve with sample size: the analytic standard errors are 63% to 76% of the empirical ones at every n tested. The point estimates are nearly unbiased. It is the uncertainty around them that is wrong, which is the more dangerous failure, because an interval that is too narrow reads as confidence.
Source artifact: results/study01_recovery.csv
npredictortrue betamean estimatebiasempirical sdcoverage 95SE ratio
100UnsafeActs0.450.45620.0062050.15190.780.6311
100OperationalStress0.30.30740.0074130.14310.79330.6696
100SystemCondition-0.25-0.2571-0.0070510.14660.79670.6579
100SafetyResponseCapability-0.2-0.2021-0.0021230.14240.82330.6746
200UnsafeActs0.450.45370.003750.097470.82330.6981
200OperationalStress0.30.30210.002060.093380.850.7279
200SystemCondition-0.25-0.2569-0.0069310.1010.79330.6741
200SafetyResponseCapability-0.2-0.19510.0049120.095680.84670.7118
500UnsafeActs0.450.45220.0022310.05650.86330.7572
500OperationalStress0.30.30150.0015040.060380.83330.7081
500SystemCondition-0.25-0.2532-0.0032140.066630.75670.6424
500SafetyResponseCapability-0.2-0.2018-0.0018390.05830.85670.7331
1000UnsafeActs0.450.45090.00087920.041030.860.7357
1000OperationalStress0.30.30070.00066620.043830.80670.6885
1000SystemCondition-0.25-0.24840.0016320.040730.84330.7421
1000SafetyResponseCapability-0.2-0.2016-0.001550.041360.840.7297
2000UnsafeActs0.450.45270.0027460.028920.84330.7366
2000OperationalStress0.30.2984-0.0015750.03050.82330.6988
2000SystemCondition-0.25-0.2503-0.00026610.031950.80.6667
2000SafetyResponseCapability-0.2-0.2013-0.0012870.030290.83330.703
5000UnsafeActs0.450.45140.0013570.018620.860.7241
5000OperationalStress0.30.30020.0001870.019420.83670.6939
5000SystemCondition-0.25-0.2514-0.001430.019370.830.6964
5000SafetyResponseCapability-0.2-0.2-3.099e-050.019550.820.6894
Bias, spread and interval coverage for each coefficient at each sample size, over 300 replications per cell.
Why
Bias is negligible everywhere. Coverage never reaches its nominal level anywhere.
Source artifact: results/study01_recovery.csv

How much data the formula would need

Estimating a coefficient and being able to tell it apart from the coefficient next to it are different tasks with very different sample size requirements.

requirementbasisn required
global fit: 80% power for the test of close fitMacCallum-style RMSEA power, df = 80153
path precision: SE = 0.05 on a single coefficientstandardized regression SE, R^2 = 0.288, VIF = 1.28369
path precision: SE = 0.025 on a single coefficientstandardized regression SE, R^2 = 0.288, VIF = 1.281459
distinguish beta = 0.45 from beta = 0.30 at 80% powerz test on the coefficient difference, correlated predictors770
distinguish beta = -0.25 from beta = -0.20 at 80% powerz test on the coefficient difference, correlated predictors6884
Sample size required for each analytic goal, computed from the same generating model.
Why
Distinguishing the two negative coefficients from one another needs a sample of 6,884, which is larger than most sites will ever have. Reporting them as separate drivers at any realistic n is reporting noise.
Source artifact: results/study01_requirements.csv
coefficient abeta acoefficient bbeta babsolute differencen for 80pct powerse of difference at n 250
UnsafeActs0.45OperationalStress0.30.157700.09456
UnsafeActs0.45SystemCondition-0.250.7410.09456
UnsafeActs0.45SafetyResponseCapability-0.20.65460.09456
OperationalStress0.3SystemCondition-0.250.55620.09456
OperationalStress0.3SafetyResponseCapability-0.20.5740.09456
SystemCondition-0.25SafetyResponseCapability-0.20.0568840.09456
Every pairwise comparison between coefficients, with the sample size that would be needed to separate the pair at 80% power.
Why
Ranking the drivers is a much harder task than estimating them.
Source artifact: results/study01_distinguishability.csv

What rare events do

Safety incidents are rare.

Why
Rare outcomes break naive estimation in a specific way: discrimination survives, calibration does not, and calibration is what an operational alert depends on.

training sampletrain ntrain event fractionauc testmean predicted testobserved rate testoverprediction ratiocalibration slopeppv at top 5pct
none (as collected)100,0000.005060.71110.0050720.005160.9831.2150.0192
random undersampling of majority10120.50.70750.4540.0051687.981.10.0188
random oversampling of minority198,9880.50.71090.45210.0051687.621.1870.0198
SMOTE-style interpolation198,9880.50.71080.44850.0051686.921.1020.0198
What resampling does to a rare-outcome model.
Why
Discrimination is essentially unchanged by rebalancing, while the predicted probabilities become meaningless: undersampling the majority class overpredicts by 88x if the intercept is not corrected.
Source artifact: results/study02_imbalance.csv
shift hoursbase rate per shiftworker shifts per eventsensitivityspecificityppvalerts per true event
89.2e-0510,8700.80.950.00147680.3
89.2e-0510,8700.80.990.007307136.9
89.2e-0510,8700.90.9990.0764713.08
89.2e-0510,8700.950.99990.46642.144
120.00013872460.80.950.002203453.8
120.00013872460.80.990.0109291.57
120.00013872460.90.9990.11059.05
120.00013872460.950.99990.56731.763
Alerts per true event at realistic base rates.
Why
This is the operational meaning of a rare outcome: at a base rate this low, even a highly specific model buries the true events under false ones, and the people receiving the alerts stop reading them.
Source artifact: results/study02_alert_burden.csv
unit of analysisbase raterelative precision targetevents requiredunits required
worker-shift (8 h)9.2e-050.1384.14,175,499
worker-shift (8 h)9.2e-050.2561.46668,080
worker-shift (12 h)0.0001380.1384.12,783,666
worker-shift (12 h)0.0001380.2561.46445,387
worker-year0.0230.1384.116,702
worker-year0.0230.2561.462672
Events and units of observation required to estimate a rare-event rate to a given relative precision.Source artifact: results/study02_sizing.csv

Zero inflation

Most establishments record no injuries in a period.

Why
That is not a small deviation from a count model, it is a different data-generating process.

structural zero fractionobserved zero fractionpoisson expected zero fractionexcess zerosmean countvariance countvariance to mean ratiools slope on latent 1
00.23670.23570.0009971.6252.0981.2910.5693
0.20.38840.23570.15271.312.1091.610.5325
0.380.52040.23570.28471.0422.031.9480.4772
Zero-inflated outcomes are the norm in injury counts.
Why
As the structural zero fraction rises, the observed zeros pull away from what a Poisson would produce and the variance-to-mean ratio climbs, while the slope on the latent variable attenuates.
Source artifact: results/study02_zero_inflation.csv

Misspecification and equivalence

This is the section that matters.

Why
A model can be wrong in the specific way that inflates the number you care about, and still fit better than the correct one.

fitted modelbeta predictor on outcometrue direct effectbiaschi squaredfcfirmseameets conventional cutoffs
confounder included0.1730.2-0.0270528.46240.99970.00682True
confounder omitted0.49550.20.29555.702810True
The same data, fitted with and without a confounder.
Why
Omitting it inflates the coefficient from 0.173 to 0.495 against a true direct effect of 0.20. Both models meet every conventional fit cutoff, and the misspecified one fits better.
Source artifact: results/study03_confounder.csv
fitted modelbetasep valuechi squaredfcfirmseasrmrmeets conventional cutoffs
Cause -> Effect (correct direction)0.49270.0158907.6968100.007859True
Effect -> Cause (reversed)0.49270.0158907.6968100.007859True
Two models with opposite causal directions, fitted to the same data.
Why
Every fit statistic is identical to the last decimal place. No amount of model comparison separates them; only an assumption from outside the data can.
Source artifact: results/study03_equivalent.csv
generating structurefitted structureestimated betasep valuechi squaredfcfirmseasrmrmeets conventional cutoffs
Injuries -> Climate, beta = -0.55Climate -> Injuries-0.56980.0150108.44280.99990.0042940.006726True
Data generated with injuries driving climate, then fitted as climate driving injuries.
Why
The reversed model recovers a large, highly significant coefficient and passes every cutoff.
Source artifact: results/study03_reverse.csv

What a coefficient means

Even a correctly estimated coefficient does not tell you what happens if you intervene, and does not survive a change of scale or a change of site.

worldfitted coefficientseffect of intervening on UnsafeActsnote
1. four direct causes, correlated exogenously[0.448, 0.306, -0.254, -0.2]0.45the only world in which the coefficient is an intervention effect
2. SystemCondition also acts through UnsafeActs[0.453, 0.307, -0.26, -0.208]0.45total effect of SystemCondition is -0.070, not -0.250; the reported coefficient is the direct effect only
3. an unmeasured common cause of UnsafeActs and Risk[0.448, 0.303, -0.246, -0.203]0.281generating direct effects are [ 0.281, 0.252, -0.298, -0.248]; the population regression on the measured predictors returns the headline vector exactly
Four causal worlds that produce nearly identical fitted coefficients, and what intervening on a variable would actually do in each.
Why
The coefficient is the same. What it licenses you to do is not.
Source artifact: results/study04_ambiguity.csv
sitecrewscore raw inputsscore z inputsranking on z inputs
Site 1 (overtime varies widely)A2.550.05625crew A scores higher
Site 1 (overtime varies widely)B3.15-0.5812crew A scores higher
Site 2 (overtime tightly controlled)A2.553.45crew B scores higher
Site 2 (overtime tightly controlled)B3.156.45crew B scores higher
Two crews carrying identical raw scores at two sites.
Why
Standardising the inputs flips the ranking at the site where overtime varies widely and leaves it intact where overtime is tightly controlled. Nothing about the crews changed between the rows; only the scaling did. A weighted-sum score is not scale-free, so the choice of scaling is a decision about who gets flagged.
Source artifact: results/study04_raw_scale.csv
quantityvalue
Site A loadings[0.806, 0.764, 0.769]
Site B loadings[0.774, 0.793, 0.301]
Site A composite reliability0.823
Site B composite reliability0.675
correlation of Site B scores under its own vs Site A's measurement model0.9463
share of Site B units whose top-decile flag flips when Site A's measurement model is imposed0.046
correlation of Site A score with the generating latent0.9103
correlation of Site B score with the generating latent0.8759
Measurement loadings at two sites.
Why
When an indicator loads differently between sites, the latent variable does not mean the same thing at both, and comparing their scores compares two different constructs.
Source artifact: results/study04_invariance.csv

Run it yourself

ehs-risk-sem
git clone https://github.com/priyatham9/ehs-risk-sem
cd ehs-risk-sem
python3 -m unittest discover -s tests -v
python3 simulations/run_all.py --quick       # writes results_quick/
python3 simulations/run_all.py               # full run, writes results/

--quick writes to results_quick/, so a reduced-replication run cannot silently replace the checked-in tables.