Lab Results — Abnormal Value Detection and Patient Trending

Lab Results — Abnormal Value Detection and Patient Trending

Laboratory data is one of the richest clinical signals. This lesson leverages the is_abnormal generated column, window functions for consecutive abnormal results, and critical-value detection.


1. Abnormal Rate by Test Type

The is_abnormal generated column (value_numeric < ref_low OR value_numeric > ref_high) lets you aggregate without repeating the logic:

SELECT
    test_name,
    COUNT(*)                                          AS total_results,
    COUNT(*) FILTER (WHERE is_abnormal)               AS abnormal_count,
    ROUND(100.0 * COUNT(*) FILTER (WHERE is_abnormal)
          / NULLIF(COUNT(*), 0), 1)                   AS abnormal_pct,
    ROUND(AVG(value_numeric), 2)                      AS mean_value,
    ROUND(MIN(value_numeric), 2)                      AS min_value,
    ROUND(MAX(value_numeric), 2)                      AS max_value
FROM hc_lab_results
GROUP BY test_name
ORDER BY abnormal_pct DESC;

What This Returns

test_name    | total_results | abnormal_count | abnormal_pct | mean_value
-------------+---------------+----------------+--------------+-----------
Troponin     |           150 |             95 |         63.3 |      0.031
BNP          |           150 |             95 |         63.3 |    150.000
Haemoglobin  |           150 |             59 |         39.3 |     11.200
...

2. Patients with 3+ Consecutive Abnormal Results

Use the LAG window function to detect runs of abnormal results for the same test:

WITH flagged AS (
    SELECT
        patient_id,
        test_name,
        collected_at,
        is_abnormal,
        LAG(is_abnormal, 1) OVER w AS prev1,
        LAG(is_abnormal, 2) OVER w AS prev2
    FROM hc_lab_results
    WHERE value_numeric IS NOT NULL
    WINDOW w AS (PARTITION BY patient_id, test_name ORDER BY collected_at)
)
SELECT DISTINCT
    lr.patient_id,
    p.name,
    p.mrn,
    f.test_name,
    COUNT(*) AS consecutive_abnormal_runs
FROM flagged f
JOIN hc_patients p ON p.id = f.patient_id
JOIN hc_lab_results lr
    ON lr.patient_id = f.patient_id AND lr.test_name = f.test_name
WHERE f.is_abnormal = true
  AND f.prev1 = true
  AND f.prev2 = true
GROUP BY lr.patient_id, p.name, p.mrn, f.test_name
ORDER BY consecutive_abnormal_runs DESC
LIMIT 20;

3. Critical Values — Extreme Outliers

Critical values (e.g., glucose > 3× upper limit or < lower/3) require immediate clinical action:

SELECT
    lr.patient_id,
    p.name,
    p.mrn,
    lr.test_name,
    lr.value_numeric,
    lr.ref_low,
    lr.ref_high,
    lr.unit,
    lr.collected_at,
    CASE
        WHEN lr.value_numeric > lr.ref_high * 3  THEN 'CRITICALLY HIGH'
        WHEN lr.ref_low > 0
             AND lr.value_numeric < lr.ref_low / 3 THEN 'CRITICALLY LOW'
    END AS critical_flag
FROM hc_lab_results lr
JOIN hc_patients p ON p.id = lr.patient_id
WHERE lr.value_numeric IS NOT NULL
  AND lr.ref_high IS NOT NULL
  AND lr.ref_low  IS NOT NULL
  AND (
        lr.value_numeric > lr.ref_high * 3
        OR (lr.ref_low > 0 AND lr.value_numeric < lr.ref_low / 3)
      )
ORDER BY lr.collected_at DESC;

What This Returns

patient_id | name       | test_name  | value_numeric | ref_high | critical_flag
-----------+------------+------------+---------------+----------+---------------
       241 | Patient 241| Troponin   |         0.144 |     0.04 | CRITICALLY HIGH
        11 | Patient 11 | BNP        |       600.000 |   100.00 | CRITICALLY HIGH
...

4. Per-Patient Lab Trend Over Time

Track how a specific metric evolves for a patient across admissions:

SELECT
    lr.patient_id,
    p.name,
    lr.test_name,
    lr.collected_at::DATE AS collected_date,
    lr.value_numeric,
    lr.ref_low,
    lr.ref_high,
    lr.is_abnormal,
    ROUND(AVG(lr.value_numeric) OVER (
        PARTITION BY lr.patient_id, lr.test_name
        ORDER BY lr.collected_at
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
    ), 2) AS rolling_3_avg
FROM hc_lab_results lr
JOIN hc_patients p ON p.id = lr.patient_id
WHERE lr.patient_id = 1
ORDER BY lr.test_name, lr.collected_at;

5. Abnormal Lab Load by Ward

Understand which wards generate the most abnormal results (proxy for patient acuity):

SELECT
    w.name                                            AS ward,
    COUNT(lr.id)                                      AS total_labs,
    COUNT(lr.id) FILTER (WHERE lr.is_abnormal)        AS abnormal_labs,
    ROUND(100.0 * COUNT(lr.id) FILTER (WHERE lr.is_abnormal)
          / NULLIF(COUNT(lr.id), 0), 1)               AS abnormal_pct
FROM hc_lab_results lr
JOIN hc_admissions a ON a.id = lr.admission_id
JOIN hc_wards w      ON w.id = a.ward_id
GROUP BY w.name
ORDER BY abnormal_pct DESC;

Key Takeaway

Generated columns like is_abnormal let you store derived flags efficiently and use them in FILTER, WHERE, and index predicates without repeating logic. The LAG(..., N) OVER (PARTITION BY ... ORDER BY ...) pattern is the standard tool for detecting consecutive states — consecutive abnormal results, consecutive payment failures, or consecutive login failures.