Skip to contents

Technical reference for the public utility functions in utils.R (recode_sex(), longwide(), simple_bmi()) and the extended BMIz functions in cdc.R (ext_bmiz() and its internal helpers z_score() and set_cols_first()).

This document describes what the current R code does in the utility and extended-BMIz functions. It is intended as a debugging aid and a long-term technical reference. References to code use function names rather than line numbers, because line numbers become stale with each code edit.


How to Read This Document

These functions sit outside the cleaning algorithm. They do not assign exclusion codes and are never called by cleangrowth(), cleanchild(), or cleanadult(). They are user-facing helpers for the steps that come after cleaning: reshaping cleangrowth() output, and computing extended CDC BMI metrics on the cleaned data.

The algorithm vignettes cover the cleaning pipeline itself. This vignette covers the surrounding toolchain.


Scope and the post-cleaning pipeline

The three utility functions plus ext_bmiz() form a natural workflow that takes cleaned long-format data through to extended BMI z-scores:

cleangrowth() output → longwide()      → wide ht/wt rows, filtered by exclusion code
                       simple_bmi()     → adds bmi column
                       ext_bmiz()       → extended + LMS BMIz, BMIp, sigma, etc.

cleangrowth() returns sex exactly as submitted (e.g. M/F or 0/1). longwide() recodes it to M/F, reading the submitted codes the same way cleangrowth() does (the default recognized codes, or the sex_coding you pass), and ext_bmiz() reads M/F directly, so callers using the standard pipeline do not need to recode sex. recode_sex() is a standalone helper for recoding one explicit pair of codes to another.

The functions accept either a data.frame or data.table. Several coerce to data.table internally for speed.


recode_sex() (utils.R)

Purpose. Recode a binary sex variable from one explicit pair of codes to another (e.g. 0/1 to M/F for ext_bmiz(), using targetm = "M" and targetf = "F"; ext_bmiz() reads only the column named sex, so use targetcol = "sex" for that). longwide() no longer uses it: it parses sex the way cleangrowth() does.

Signature.

recode_sex(input_data,
           sourcecol = "sex",
           sourcem   = "0",
           sourcef   = "1",
           targetcol = "sex_recoded",
           targetm   = 1L,
           targetf   = 2L)

Parameters. sourcecol is the existing sex column; sourcem/sourcef are the values it uses for male/female. targetcol is the column to write, which may be sourcecol itself; targetm/targetf are the values written for male/female. Defaults map sex 0/1 → sex_recoded 1/2.

Mechanics. Coerces to data.table, matches sourcecol against sourcem and sourcef, then writes targetcol from those matches: targetm, targetf, or NA for rows matching neither value.

Output. Returns a data.table with the added/updated targetcol. The input is not modified, and unless targetcol is sourcecol the source column is preserved, so the recode can be chained (e.g., recode back from text labels to numbers).


longwide() (utils.R)

Purpose. Transform long-format growthcleanr data (one row per measurement) into wide format (one row per subject-age with paired height and weight), filtered to the exclusion codes the caller wants to keep. This is the reshape step that prepares cleaned data for BMI computation.

Signature.

longwide(long_df,
         id                  = "id",
         subjid              = "subjid",
         sex                 = "sex",
         agedays             = "agedays",
         param               = "param",
         measurement         = "measurement",
         gcr_result          = "exclude",
         include_all         = FALSE,
         inclusion_types     = c("Include"),
         extra_cols          = NULL,
         keep_unmatched_data = FALSE,
         sex_coding          = <cleangrowth() default codes>,
         param_coding        = NULL)

Input expectations. A long data frame with the named id/subjid/sex/agedays/param/measurement columns plus an exclusion column. The gcr_result default is "exclude", which matches the cleangrowth() output column from version 3 onward — so a cleangrowth() result data.table can be passed (or merged onto the input) directly. For data with a differently named exclusion column, pass gcr_result explicitly. If the column is missing and the default "exclude" was used, the error suggests gcr_result = "gcr_result" (the column name 2.x scripts used).

Mechanics.

  1. Column check and renaming. Errors if any specified column (including extra_cols) is absent. The six required columns are read by the names passed in id, subjid, sex, agedays, param and measurement and renamed to those internal names, so a column that merely happens to be named, say, subjid is never read when subjid = "patid" is passed. gcr_result and extra_cols may not reuse a name longwide() needs (a required column, or an output column such as agey or ht); that is an error.
  2. Inclusion filter. If include_all = TRUE, all rows are kept and inclusion_types is ignored. Otherwise only rows whose gcr_result is in inclusion_types (default c("Include")) are kept. An inclusion_types value that is not a version 3 exclusion code (Summary or Detailed, or a bin_exclude/tri_exclude value), such as the 2.x code "Exclude-Carried-Forward", matches nothing, so longwide() warns and points to the code mapping in vignette("experienced-start-here").
  3. Derived ages. Adds agey (agedays / 365.25, rounded to 4 places) and agem (agey * 12).
  4. Sex recode. Parses sex with the same parser cleangrowth() uses — the default recognized codes (0/1, m/M, f/F, b/g, male/female, boy/girl; case- and whitespace-insensitive) or the user’s sex_coding list — and overwrites sex with M or F. A retained row whose sex is missing or unrecognized gets NA sex, and longwide() warns with the count and up to five unrecognized values (ext_bmiz() cannot compute BMIz for those rows). Pass the same sex_coding given to cleangrowth() if you used one, because cleangrowth() returns sex as entered, with no record of the coding. With the default coding, longwide() makes the same two checks as cleangrowth(), on the whole sex column before rows are filtered and at every age: it stops if the values are only 1 and 2 with at least one 2, and gives a prominent warning if every value is 1 across more than one subject. Passing sex_coding removes both.
  5. Param and measurement. Reads param the way cleangrowth() does (its output returns param and measurement verbatim): param_coding labels resolve to a canonical parameter, imperial values are converted to metric (HEIGHTIN/LENGTHIN × 2.54, WEIGHTLBS ÷ 2.2046226), and LENGTHCM is treated as HEIGHTCM (label only; no length adjustment). Only height and weight rows are kept: head circumference is dropped silently, and a row with an unrecognized parameter is dropped with a warning naming up to five values. Pass the same param_coding given to cleangrowth() if you used one. So ht and wt in the output are always cm and kg.
  6. Unique ids. The function requires unique ids among the kept height and weight rows and errors on duplicates.
  7. Height/weight separation and re-merge. Height rows and weight rows with a non-missing measurement are split out, tagged with ht_id/wt_id, then merged on subjid, agey, agem, agedays, sex. keep_unmatched_data controls whether the merge is an inner join (default, all = FALSE) or a full outer join that retains heights without a same-day weight and vice versa. The merged measurements are exposed as ht and wt.
  8. extra_cols handling. For each extra column, the height-side (.x) and weight-side (.y) values are compared. If they match on every row (treating NA/NA as a match), a single merged column is kept. Otherwise the function keeps both as ht_<col> and wt_<col> plus a logical match_<col> column flagging where they agree.
  9. Drops the intermediate HEIGHTCM/WEIGHTKG columns (their values live in ht/wt) and gives the subject and age columns back the names passed as subjid and agedays.

Output. A wide data frame with one row per matched subject-age, carrying the subject column (under the name passed as subjid), the two ids, agey/agem/agedays, recoded sex (M or F), ht, wt, and any requested extra columns. Suitable for simple_bmi() then ext_bmiz().

Notes / pitfalls.

  • The default inner join silently drops measurements without a same-day partner of the other parameter. Set keep_unmatched_data = TRUE to retain them.
  • Duplicate ids are a hard error — the wide pivot depends on a unique per-row id.
  • No optional packages are needed; longwide() uses base R only. If no rows are kept (or there are heights but no weights), it returns a data frame with the usual columns and no rows.

simple_bmi() (utils.R)

Purpose. Add a BMI column to wide-format data using the standard formula. Intended for output from longwide().

Signature.

simple_bmi(wide_df, wtcol = "wt", htcol = "ht")

Mechanics. Verifies wtcol and htcol are present (errors otherwise), coerces to data.table if needed, and computes bmi := wt / (ht * 0.01)^2 — i.e., weight in kg over height in meters squared (height is supplied in cm, hence the 0.01 factor).

Output. The input as a data.table with an added bmi column.


ext_bmiz() (cdc.R)

Purpose. Compute the extended BMI metrics defined by the CDC, alongside the standard LMS z-scores and percentiles, for children aged 2–19.9 years. The extended BMIz was introduced to better characterize BMI above the 95th percentile, where the original LMS distribution is compressed and poorly discriminates severe obesity. For BMI at or below the 95th percentile the extended values equal the LMS values; they diverge only above it.

This function uses the R code published on the CDC website and the cdcanthro R package. It is directly relevant to research comparing extended vs. original CDC BMIz. The extended BMI method is described in:

Wei R, Ogden CL, Parsons VL, Freedman DS, Hales CM. A method for calculating BMI z-scores and percentiles above the 95th percentile of the CDC growth charts. Ann Hum Biol. 2020 Sep;47(6):514-521. doi: 10.1080/03014460.2020.1808065. PMCID: PMC11232929.

Signature.

ext_bmiz(data,
         age                = "agem",
         wt                 = "wt",
         ht                 = "ht",
         bmi                = "bmi",
         adjust.integer.age = TRUE,
         ref.data.path      = "")

Input expectations.

  • data has at least age, sex, weight, height, and BMI columns. The default column names (agem, wt, ht, bmi) match longwide() + simple_bmi() output; remap via the arguments if different.
  • age must be in months, using the most precise value available (years × 12, or days ÷ 30.4375). Only ages 24–240 months are evaluated; others are dropped from the computation (but see merge-back below).
  • sex should be text: M/F, which is what longwide() returns, or another text code that works the same as in cleangrowth() (for example b/g or male/female). Numeric sex is accepted only if the column has both 1s and 2s and no other values, read as 1 = male, 2 = female — different from cleangrowth()’s numerical 0/1, for historical reasons. The 1/2 coding matches the CDC reference data, CDC software, and the cdcanthro package. Any other numeric value stops the run. The column must be named sex.
  • wt in kg, ht in cm.
  • adjust.integer.age — if all age values are integers, add 0.5 (placing integer ages at mid-month, as the CDC macro does). Disable by setting FALSE.
  • ref.data.path — directory holding the reference file. Empty default loads the packaged inst/extdata/CDCref_d.csv.gz via system.file(); override only when developing against a local copy.

Mechanics (high level).

  1. Checks that the named input columns (and sex) exist, and warns if data already has a column named like one of the output columns (for example, from an earlier run): the returned data has the newly calculated values in that column. Copies data (dorig) and reads the four named columns plus sex into a separate working table with a seq_ row index; data itself is never modified and its column names are kept.
  2. Optionally bumps integer ages by 0.5 (in the working table only; the returned age column is as entered).
  3. Stops if sex has any numeric value unless the present values are exactly 1 and 2 (see Notes / pitfalls), then normalizes sex to sexn ∈ {1, 2} via first-character matching (logical values are not recognized).
  4. Restricts to ages 24–240 months with at least one of wt/ht present.
  5. Loads the CDC LMS reference (CDCref_d.csv.gz, denom == "age"), assembling per-month L/M/S parameters for weight, height, and BMI (including the age-240 row used for the adjusted-distance reference mref/sref).
  6. For ages not present in the reference grid, linearly interpolates the L/M/S values per sex (approx()).
  7. Computes LMS z-scores and modified z-scores for weight, height, and BMI via z_score() (see below).
  8. Derives percentiles (pnorm), the 95th and 97th BMI percentiles (p95, p97), bmip95 (BMI as a percent of the 95th percentile), the obese / severe-obese flags, and the alternative distance/percent-distance metrics from PMID 31439056.
  9. Extended BMIz/percentile. Computes the half-normal scale parameter sigma (a sex-specific quadratic in age in years). For rows with LMS BMI percentile ≥ 95, replaces the percentile with 90 + 10 * pnorm((bmi - p95) / sigma) and the z-score with qnorm(ebp/100). Where the extended percentile is so close to 100 that qnorm returns infinity (only above 99.99999999999999, e.g. an implausibly high BMI at a young age — likely data error), the extended z is capped at 8.21.
  10. Renames internal columns to the public output names, selects the output set, and adds the new metrics to the copy dorig, matched by seq_ so every original row and column is returned in the original order (rows outside the age range get NA metrics). The input columns keep the user’s names. Variable labels (descriptions such as “Age (months)”) are attached as each column’s label attribute (on the copy).

Key output columns. bmiz/bmip (LMS or extended, identical below the 95th percentile), original_bmiz/original_bmip (always LMS), mod_bmiz/mod_waz/mod_haz (modified z-scores), waz/wp/haz/hp (weight- and height-for-age LMS z/percentile), p95/p97/bmip95, sigma, and the obese/sev_obese flags (sev_obese = BMI ≥ 120% of the 95th percentile). See ?ext_bmiz for the full column set.

Notes / pitfalls.

  • Numerical sex coding (1/2) differs from cleangrowth() (0/1), so ext_bmiz() stops on numeric sex unless the column has both 1s and 2s and no other values. This includes a column of only 1s, which could be all-male 1/2 data or all-female 0/1 data, and a column of only 2s. Numeric text and factor values are treated the same way. Single-sex data should be coded M or F. If 0 or another number means unknown, set it to NA. A warning gives the count of rows aged 24–240 months with a missing or unrecognized sex.
  • Age units are months, not days or years.
  • Rows outside 24–240 months are excluded from the metrics but still returned (with NA metrics) because the result is merged back onto the original rows.
  • Age 730 days converts to 23.98 months (730 / 30.4375), which is below 24 months, so a row at agedays 730 gets NA metrics even though a child at that age may be exactly 2 years old; metrics begin at day 731. This matches the cdcanthro package. A warning gives the number of rows at about 729–730 days.
  • The qnorm-infinity cap at 8.21 is intentional and matches the CDC macro’s handling.
  • Dependencies: data.table, R.utils (except extract), and stats::approx/pnorm/qnorm, all from Imports; no optional packages are needed.

Internal helpers (cdc.R, not exported)

z_score(var, l, m, s) — Given a measurement and its LMS parameters, returns a two-element list: the standard LMS z-score (((var/m)^l) - 1) / (l*s) and a modified z-score. The modified z-score expresses the value as a multiple of half the distance from the median to the ±2 SD points (sdm2 below the median, sdp2 above), which keeps extreme values on a more interpretable scale than the raw LMS z. Used for all three parameters (weight, height, BMI).

set_cols_first(DT, cols, intersection = TRUE) — Reorders a data.table so cols come first (via setcolorder). With intersection = TRUE (default) only columns actually present are moved, so it is safe to pass a superset. Used to restore the original column order on the merged-back result. (Adapted from the hutils package.)