跳到正文
DEV Community· Dr. Barış Öztürk, MD·· 4 小時前AI 評分49

WHO 與 CDC 生長百分位數如何以 LMS 方法計算,以及遇到的邊界情況

How WHO and CDC growth percentiles are computed (the LMS method), and the edge cases we hit

AI 導讀

文章説明 WHO 與 CDC 如何以 LMS 方法計算兒童生長百分位數,並整理 Growo 計算器實作時遇到的七個邊界情況。內容涵蓋年齡對應資料列、CDC 的 L、M、S 插值、只有百分位曲線時在 z-score 空間插值,以及尾端數值的處理。WHO 資料按日提供,CDC 嬰兒資料列則位於月份中間,錯誤索引可令百分位數偏離。

正文

I'm an emergency physician, and I also write the code behind Growo, a set of child growth calculators in 17 languages and an MCP server that AI assistants can call. Parents kept asking me what the percentile on their child's chart meant. Answering properly meant computing it the way the WHO and the CDC do, then finding every place where a correct formula still gives the wrong number.

This post covers the formula, about 20 lines of TypeScript, and seven edge cases we hit. Every number below was recomputed with the production engine while writing.

The LMS method in one formula

The WHO and CDC growth charts are built with the LMS method (Cole and Green). For every age they publish three parameters:

  • M: the median
  • S: the coefficient of variation, which sets the spread
  • L: a Box-Cox power that removes the skew. Weight is right-skewed, so its L is not 1

A measurement X becomes a z-score, and the z-score becomes a percentile through the standard normal CDF:

z = ((X / M)^L - 1) / (L * S)    when L != 0
z = ln(X / M) / S                when L == 0  (the limit)
percentile = Φ(z) * 100

JavaScript has no built-in Φ, so we use a polynomial approximation:

// Standard normal CDF: Zelen & Severo (Abramowitz & Stegun 26.2.17), |error| < 7.5e-8
const normalCdf = (x: number): number => {
  const t = 1 / (1 + 0.2316419 * Math.abs(x));
  const d = 0.3989423 * Math.exp((-x * x) / 2);
  const p = d * t * (0.3193815 + t * (-0.3565638 + t * (1.781478 + t * (-1.821256 + t * 1.330274))));
  return x > 0 ? 1 - p : p;
};

// Measurement -> z-score, and back (the inverse draws the curves)
const lmsZ = (x: number, L: number, M: number, S: number): number =>
  Math.abs(L) < 1e-7 ? Math.log(x / M) / S : (Math.pow(x / M, L) - 1) / (L * S);

const valueAtZ = (z: number, L: number, M: number, S: number): number =>
  Math.abs(L) < 1e-7 ? M * Math.exp(S * z) : M * Math.pow(1 + L * S * z, 1 / L);

// 6-month-old girl, 7.2 kg: WHO weight-for-age, day 183
const z = lmsZ(7.2, -0.0759, 7.3016, 0.12204);
console.log(z.toFixed(3), (normalCdf(z) * 100).toFixed(1)); // -0.115 45.4
console.log(valueAtZ(-1.881, -0.0759, 7.3016, 0.12204).toFixed(2)); // 5.82 kg = 3rd percentile line

That girl is at the 45.4th percentile. The formula was the easy part.

Where L, M and S come from

Reference Ages Grid
WHO Child Growth Standards (2006) 0 to 1,856 days One row per day
CDC growth charts (2000) 0 to 36 months (lying length), 24 to 240 months (standing height) Rows at 0, 0.5, 1.5, 2.5 … months
Olsen 2010 (size at birth) 23 to 41 completed weeks of pregnancy One row per week, weight in grams
Neyzi (Türkiye) 0 to 18 years Percentile curves only, no L, M, S

The CDC recommends the WHO standards under 2 years in the US and its own charts from 2 to 20. Our percentile calculator opens on WHO in most languages and on the national reference in Turkish (Neyzi), Arabic (Saudi) and Korean (KDCA, which is WHO below 36 months). BMI under 24 months is always WHO, because the CDC's BMI chart starts at 2.

Edge case 1: a month is 30.4375 days

The WHO publishes one row per day and defines a month as 365.25 / 12 = 30.4375 days. When an API caller sends ageMonths: 1, you have to choose a row. We round to the nearest day, so one month becomes day 30. A boy at exactly the WHO's published one-month median (4.4709 kg) then reads 51.2 instead of 50. The gap is about 1 point at one month and under half a point from two months on. The web calculator counts whole days from the birth date, so it never hits this case.

Edge case 2: CDC rows sit in the middle of the month

The CDC's infant rows are at 0, 0.5, 1.5, 2.5 … months. The 0.5 row stands for the first month of life. If you index the rows by whole months, a one-month-old gets the 0.5 row, and a boy exactly on the median reads the 78th percentile. He reads the 61st at 6 months and the 55th at 12. Interpolate L, M and S instead:

type Lms = { L: number; M: number; S: number };
const lerp = (a: number, b: number, f: number) => a + (b - a) * f;

// CDC rows sit at 0, 0.5, 1.5, 2.5 ... months. Interpolate; never index by whole months.
function cdcLmsAt(ages: number[], rows: Lms[], months: number): Lms {
  const last = ages.length - 1;
  if (months <= ages[0]) return rows[0];
  if (months >= ages[last]) return rows[last]; // clamp, never extrapolate L/M/S
  const hi = ages.findIndex((a) => a >= months);
  const f = (months - ages[hi - 1]) / (ages[hi] - ages[hi - 1]);
  const [a, b] = [rows[hi - 1], rows[hi]];
  return { L: lerp(a.L, b.L, f), M: lerp(a.M, b.M, f), S: lerp(a.S, b.S, f) };
}

With interpolation, every published CDC median comes back as the 50th percentile.

Edge case 3: charts that publish percentiles, not LMS

Neyzi, like several national charts, publishes only the curves (3rd, 5th, 10th … 97th). Interpolate between the curves in z-space: map each curve to its z (the 3rd is -1.881), interpolate z linearly with the measurement, then apply Φ. Interpolating the percentile directly looks equivalent but fails in the tails. We tested both on the CDC's own birth-weight curves for boys, where the true answer is known:

True position Interpolated as a percentile Interpolated as a z-score
1st, extended from the 3rd/5th curves -1.2 (below zero) 0.9
10th, with only the 3rd/50th/97th curves 19.7 11.3
25th, with only the 3rd/50th/97th curves 34.8 27.2

Past the outermost curve, we continue the slope of the outermost pair in z-space.

Edge case 4: the tails

Rounded to one decimal, a percentile reads 0.0 or 100.0 once |z| passes about 3.3, so out there the z-score is the more useful number. Be careful which z-score you mean, though. For weight-based indicators beyond ±3 SD, the WHO switches to a restricted method that measures the distance past the 3 SD curve in units of the 2 SD to 3 SD gap. Our engine uses the plain formula at every z, so beyond 3 SD its weight and BMI z-scores can differ from the WHO software's. Our reporting treats "3 SD or more" as a band of its own rather than relying on that decimal.

Edge case 5: lying length and standing height at 24 months

Babies are measured lying down and 2-year-olds standing up, and standing reads a little under a centimetre shorter. The WHO table switches between day 730 and day 731, and its median drops 0.67 cm there. The CDC publishes two tables that overlap from 24 to 36 months, with medians 0.80 cm apart at 24 months. If you read a 2-year-old's lying length on the standing chart, a median child moves to the 59th percentile. Choose the table by age (we switch at 24 months) and say which one you used.

Edge case 6: numbers typed in 17 languages

parseFloat("6,5"); // 6

A 6-month-old girl who weighs 6.5 kg is at the 16.9th percentile. Read as 6 kg, she drops to the 5.3rd, and nothing on the screen says so. The MCP server now accepts a decimal comma and refuses anything that is not entirely a number:

const asNumber = (text: string): number =>
  /^-?\d+([.,]\d+)?$/.test(text.trim()) ? Number(text.trim().replace(",", ".")) : NaN;

asNumber("6,5");   // 6.5
asNumber("6 kg");  // NaN -> "must be a number"
asNumber("6,5,2"); // NaN

Units are a similar problem. The Olsen tables are in grams, so the birth-size percentile tool refuses 3.4 with "looks like kilograms, pass 3400" rather than computing a percentile for a 3.4 g baby.

Edge case 7: a baby born in the future

new Date("2026-10-04") is midnight UTC. In Türkiye (UTC+3) that is 03:00 local time, so between midnight and 3 a.m. a baby born today was "born in the future" and refused. At UTC+14 that window is 14 hours. Treat a birth date as a calendar date:

const [y, m, d] = dob.split("-").map(Number);
const birth = new Date(y, m - 1, d); // local midnight, not UTC
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const ageDays = Math.round((today.getTime() - birth.getTime()) / 86_400_000); // DST days are 23 or 25 h

Reporting without interpreting

The MCP server (Streamable HTTP, no key) returns the percentile, the z-score, the 3rd/15th/50th/85th/97th values at that age and the chart's name. It also returns interpretation: null and isDiagnostic: false. A deviationBand field takes one of three values:

  • within: between the 3rd and 97th percentiles
  • outside: beyond the drawn 3rd to 97th band (|z| ≥ 1.881)
  • marked: 3 SD or more from the median, which is the WHO's severe cut-off

The last two get a note suggesting a clinician look at the measurement. A position on a reference chart is a fact a tool can state. What it means for one child depends on that child's history and an examination, and no calculator has either.

How we checked it

  • CDC: each CDC table lists its 3rd to 97th percentile values next to L, M and S. We fed all 9,882 of them (weight, length and head circumference from 0 to 36 months, weight and stature from 2 to 20 years, both sexes) back through the engine at their own ages. Every one came back as its own column, to one decimal. This tests the formula, the interpolation and Φ, not the data.
  • WHO: we compared 12 readings with Korea's official KDCA calculator, which uses WHO below 36 months. All agreed within 0.1 percentile point.
  • Neyzi: the z-space method returns each of the 658 published points as its own percentile.
  • One engine: the MCP server imports the same module as the web calculator, so an assistant and a website visitor get the same answer.

If you build anything with growth charts, I'd like to hear which edge case bit you.


Sources: WHO Child Growth Standards · WHO methods and development (2006) · CDC growth chart data files · CDC 2000 growth charts: methods and development · Olsen et al., Pediatrics 2010 · Neyzi et al., J Clin Res Pediatr Endocrinol 2015

來源:DEV Community · dev.to