DEV Community

Multigrid
Multigrid

Posted on • Originally published at multigrid.ai

Extracting Vital Signs From a Clinical Note

Vitals look like the easiest extraction in the chart: seven numbers, all of them small. They are not, because the same seven numbers appear twice in two layouts, the units are implied by local convention rather than written down, and a plausibility check catches one unit error and not the other.

The same vitals, twice, in two formats

Nearly every note carries vitals in an inline run — a compressed sentence fragment near the top of the physical exam — and, if the note was generated from an EHR, again in a flowsheet table with a column per measurement time. The inline form looks like this:

VS: T 98.6 BP 128/82 HR 76 RR 16 SpO2 98% on RA Wt 165 lb Ht 5'9"
Enter fullscreen mode Exit fullscreen mode

Two things about that string matter. It is positional and abbreviation-driven, so the label carries the meaning and the label set varies by institution: temperature is T or Temp, oxygen saturation is SpO2 or O2 sat or Sat, and RA means room air and is a qualifier on the saturation, not a separate vital. And it is a summary. The flowsheet, where present, holds every measurement taken during the encounter, and the inline run usually holds only the triage set or the most recent one.

When the two disagree, they are usually both right and refer to different times. This makes “extract the vital signs” an underspecified request, and the correct schema is not one object with seven fields but a list of observations, each with a value, a unit, a time and a source. Flattening to the latest value is a decision the consumer should make, not the extractor.

The flowsheet has its own layout hazard: it is a table where time runs across the columns and measurement type down the rows, and it is frequently the widest thing on the page, so it wraps or continues onto a second page with the row labels repeated and the times different. Extracting it as text loses the column association entirely. This is a reading-order problem rather than a prompting problem — the same class of failure that layout-aware document understanding exists to handle, and the continuation case specifically is column misalignment across pages.

Blood pressure is two observations in one string

128/82 is not a value. It is two values, systolic and diastolic, printed with a solidus, and in every coded representation they are separate observations — LOINC gives systolic blood pressure the code 8480-6 and diastolic 8462-4, with the panel itself coded separately. A schema with a single blood_pressure string is storing a display format rather than data, and every consumer then reimplements the split, differently.

Split it at extraction, keep the printed string alongside, and handle the variants: a mean arterial pressure sometimes follows in parentheses, an arterial line reading may be printed with the site, and an orthostatic set gives two or three pairs with positions attached (“supine”, “standing”) which are a qualifier on the observation and are meaningless once the pairs are merged.

The unit is usually not printed

Temperature is the clean case. T 98.6 has no unit at all, and the reader supplies Fahrenheit from context. T 37.0 is the same measurement in Celsius, also unlabelled. The conversion is exact:

C = (F - 32) x 5/9          F = C x 9/5 + 32

  98.6 F -> (98.6 - 32) x 5/9 = 66.6 x 0.5556 = 37.0 C
  38.5 C -> 38.5 x 1.8 + 32 = 101.3 F

Mass:    1 lb = 0.45359 kg      165 lb -> 74.8 kg
Length:  1 in = 2.54 cm         69 in  -> 175.3 cm
Enter fullscreen mode Exit fullscreen mode

Height is the worst offender for a different reason: it is written as feet and inches with punctuation that is not arithmetic. 5'9" is 69 inches, and a parser that reads the apostrophe as a decimal point gets 5.9 of something. The same note may print height in centimetres in the flowsheet and in feet and inches inline.

The rule that survives all of this is: never write a number into a unit-typed field without also writing the unit you believe it to be in, and never infer the unit from the field name. Store value, unit, and unit_source — printed, inferred from magnitude, or defaulted by configuration. When somebody later asks why a patient weighs 74 kilograms in one row and 165 in the next, unit_source is the field that answers it.

Range gates, and the one they miss

A plausibility gate is the cheapest validation available, and for temperature it is decisive. Human body temperature compatible with life sits roughly between 30 and 43 degrees Celsius, or 86 to 109 Fahrenheit. So a value of 98.6 arriving in a field typed as Celsius is not merely unlikely, it is impossible, and the gate has just detected a unit error with certainty. The same logic works for height: 69 in a centimetres field is impossible for an adult, 175 in an inches field is impossible for anybody.

Now the case that defeats it. A weight of 70 is entirely plausible in kilograms and entirely plausible in pounds. So is 60, 80, 90 and every adult weight in the overlapping band. A range gate on weight passes both readings and cannot distinguish them, and the error it lets through is a factor of 2.2 — which is exactly the magnitude that matters if the number is going anywhere near a weight-based calculation.

Three things do work where the gate does not:

  • Require the unit token from the document. If the source did not print lb or kg, do not guess — emit the observation with a null unit and let it fail loudly rather than defaulting.
  • Cross-check against another measurement of the same thing. If the flowsheet has a weight in kilograms and the narrative has one without a unit, the ratio between them settles it. A ratio near 2.2 is a unit mismatch; a ratio near 1.0 is agreement.
  • Check internal consistency. Where height, weight and a body mass index are all printed, the BMI is derived from the other two and can be recomputed. A BMI that only agrees when weight is read as kilograms tells you which reading is right, and this is the one arithmetic check on the whole vitals block that is genuinely self-validating.

Coding and timestamping the result

Vitals have stable LOINC codes, which is what makes them worth coding rather than keying on a label string: 8867-4 for heart rate, 9279-1 for respiratory rate, 8310-5 for body temperature, 8302-2 for body height, 29463-7 for body weight, 8480-6 and 8462-4 for the two blood pressures, and 59408-5 for oxygen saturation measured by pulse oximetry. That last one is worth noticing: saturation by pulse oximetry and saturation from an arterial blood gas are different LOINC concepts because they are different measurements, and the note distinguishes them only by the abbreviation used.

Timestamping is where an otherwise-correct extraction becomes useless. A flowsheet column header is frequently a bare time with no date, under a table that spans midnight, and the date has to come from the encounter. Emit an explicit datetime with a timezone, or emit null and a note saying the source printed a time only — the general treatment of both halves of that is in date field validation and missing required field handling. Silently attaching the document date to a 02:15 reading that belongs to the following morning creates a record that is wrong by a day and looks perfect.

LOINC is published by the Regenstrief Institute and released twice a year; the codes above are long-standing, but confirm any code you persist against the current release at loinc.org rather than against a copy in a wiki. The final digit after the hyphen is a mod-10 check digit, so a mistyped code is often detectably invalid — but membership in the released table is the real test, not the arithmetic.

Related

Top comments (0)