Skip to contents

Screens measurement.value_as_number for artifacts such as a systolic blood pressure of 0 or 999, negative lab values, or NaN/Inf, and returns the numeric measurements with a sanitized value, the raw value and a per-row status (RFC 6.1). Everything is computed in DuckDB SQL (no temporary or persistent objects are created and the database is only read), so it works on a read-only connection and gives the same result as omop_etl.sanitize_measurements() in Python.

Usage

sanitize_measurements(
  con,
  cohort_table = NULL,
  method = "dqd_biologic_limits",
  action = "nullify",
  outlier_action = NULL,
  measurement_concept_ids = NULL,
  limits = NULL,
  iqr_multiplier = 3,
  z_threshold = 4,
  person_col = "subject_id",
  measurement_table = "measurement"
)

Arguments

con

Active DuckDB DBI connection to the OMOP CDM database (read-only is fine).

cohort_table

Optional table or view ("name" or "schema.name") restricting the scope to the persons listed in its person_col. Duplicate cohort rows do not duplicate measurements. NULL = everyone.

method

One of "dqd_biologic_limits" (default), "winsorize_iqr" or "z_score_cutoff".

action

One of "nullify" (default), "clamp" or "drop_row".

outlier_action

Optional alias for action (e.g. "winsorize", "drop").

measurement_concept_ids

Optional integer vector: only sanitize these measurement_concept_id values. NULL = all concepts.

limits

Optional data.frame of custom limits for "dqd_biologic_limits" with columns concept_id, min_value, max_value and optionally unit_concept_id (names are case-insensitive, extra columns are ignored). See Details.

iqr_multiplier

Fence width k for "winsorize_iqr" (default 3; 1.5 is the classic Tukey fence). Must be positive and finite.

z_threshold

Cut-off for "z_score_cutoff" (default 4). Must be positive and finite.

person_col

Person id column of cohort_table (default "subject_id"). It must exist; there is no fallback to another column.

measurement_table

Optional measurement table or view name (default "measurement").

Value

A data.frame with one row per in-scope measurement (rows whose value_as_number is NULL are not part of the result), ordered by measurement_id, with columns measurement_id, person_id, measurement_concept_id, measurement_date, unit_concept_id, value_as_number (sanitized), value_as_number_raw, sanitize_status and, when the measurement table has them, measurement_datetime and visit_occurrence_id. sanitize_status is one of "ok", "below_min", "above_max", "invalid_number", "no_limit", "unit_skipped" or "insufficient_data". The per-concept report is attached as attr(x, "sanitization_report").

Details

Methods. "dqd_biologic_limits" compares each value with the bundled per-concept, per-unit plausibility limits (inst/extdata/physiologic_limits.csv, see system.file("extdata", "physiologic_limits.csv", package = "omopduckdb")) or with limits. "winsorize_iqr" flags values outside the Tukey fences Q1 - k*IQR .. Q3 + k*IQR of their (concept, unit) group (k = iqr_multiplier). "z_score_cutoff" flags values with |z| > z_threshold using the group mean and sample standard deviation.

Actions. "nullify" sets a flagged value to NA; "clamp" moves it to the violated bound (for "winsorize_iqr" the fence, for "z_score_cutoff" mean +/- z_threshold * sd); "drop_row" removes the row from the result.

Invalid numbers. NaN and +/-Inf are invalid under every method, whether or not a limit exists (sanitize_status = "invalid_number"). "nullify" and "drop_row" behave as usual. "clamp" moves +Inf/-Inf to the group's upper/lower bound when there is one, and falls back to NA for NaN and whenever no finite bound applies.

Inclusive bounds. A value equal to a bound is "ok".

Units ("dqd_biologic_limits"). A limit row applies only when the measurement's unit_concept_id equals the row's. A concept that has limits only in other units, or a measurement with a NULL/0 unit, is left untouched and reported as "unit_skipped" (a creatinine in umol/L is never compared with the mg/dL bounds). A concept with no limit row at all is "no_limit".

Custom limits. A limits row with a unit_concept_id replaces the bundled row for that concept and unit (or adds one). A row without a unit (NA, or no such column) is an any-unit limit: it replaces every bundled row of the concept and applies whatever unit the measurement has, including NULL/0. A missing min_value or max_value leaves that side open. A unit-specific row wins over an any-unit row of the same concept. Passing limits with another method warns and is ignored.

Statistical methods. Statistics are computed per (measurement_concept_id, unit_concept_id) (a NULL unit is its own group) over the finite, in-scope values, i.e. after the cohort and concept restrictions. A group with fewer than 3 finite values, a zero IQR ("winsorize_iqr") or a zero or undefined standard deviation ("z_score_cutoff") has no usable spread: its values are kept and reported as "insufficient_data". measurement_concept_id = 0 (unmapped) is never pooled into a group and is reported as "no_limit". Quartiles use linear interpolation (DuckDB quantile_cont, the same as R's quantile(type = 7) and numpy's default).

The default "dqd_biologic_limits" never guesses a unit, so an ETL that leaves unit_concept_id at 0 (this package's PCORnet ETL does for lab results: the text stays in unit_source_value) gets no screening of those rows. A warning is raised when more than half of the measurements of a concept with limits are skipped for that reason: map the units in the ETL, or pass limits rows without unit_concept_id (an any-unit limit) for the concepts you want screened.

Choosing an action. "nullify" (or "drop_row") is the right action for the artifacts "dqd_biologic_limits" finds: a blood pressure of 0 or 999 is a sentinel, not an extreme measurement, and "clamp" would turn it into 40 or 300, a plausible-looking value that downstream models treat as real. Use "clamp" (winsorization) only for values that are extreme but real.

Skewed data and masking. "winsorize_iqr" and "z_score_cutoff" assume a roughly symmetric distribution. Right-skewed labs (creatinine, ALT, CRP, ...) have a long valid tail, so clinically important values (an AKI creatinine of 3-6 mg/dL) can be nullified or clamped: prefer "dqd_biologic_limits" for such labs, or transform them first. "z_score_cutoff" is also not robust: the outliers themselves inflate the mean and standard deviation, and with a sample standard deviation no value of a group of n can have |z| above (n - 1) / sqrt(n), so the default z_threshold = 4 cannot flag anything in a group of fewer than 18 values.

Report. attr(x, "sanitization_report") is a data.frame with one row per measurement_concept_id and unit_concept_id and columns measurement_concept_id, unit_concept_id, n, n_ok, n_below, n_above, n_invalid, n_changed, n_dropped, n_no_limit, n_unit_skipped and n_insufficient_data. It counts every in-scope row, including dropped ones, and reconciles exactly: n = n_ok + n_below + n_above + n_invalid + n_no_limit + n_unit_skipped + n_insufficient_data and n_changed + n_dropped = n_below + n_above + n_invalid.

Examples

con <- DBI::dbConnect(duckdb::duckdb())
#> duckdb keeps downloaded extensions and secrets in a temporary directory:
#> ℹ /tmp/RtmpFV3zps/duckdb
#> This is removed when the R session ends.
#> • Extensions are re-downloaded each session.
#> • Secrets are lost.
#> ℹ Run duckdb(shared_home = TRUE) (or create ~/.duckdb) to keep them (suitable for most users).
#> ℹ Run duckdb(shared_home = FALSE) to accept the temporary directory (and silence this message).
#> ℹ See ?duckdb_storage for details and alternatives.
DBI::dbExecute(con, "
  CREATE TABLE measurement AS SELECT * FROM (VALUES
    (1, 1, 3004249, DATE '2021-01-01', 8876, 120.0),
    (2, 1, 3004249, DATE '2021-01-02', 8876, 999.0),
    (3, 2, 3004249, DATE '2021-01-03', 8876, 0.0),
    (4, 2, 3016723, DATE '2021-01-04', 8749, 88.0)
  ) AS t(measurement_id, person_id, measurement_concept_id, measurement_date, unit_concept_id,
         value_as_number)")
#> [1] 4
clean <- sanitize_measurements(con)
clean[, c("measurement_id", "value_as_number", "value_as_number_raw", "sanitize_status")]
#>   measurement_id value_as_number value_as_number_raw sanitize_status
#> 1              1             120                 120              ok
#> 2              2              NA                 999       above_max
#> 3              3              NA                   0       below_min
#> 4              4              88                  88              ok
attr(clean, "sanitization_report")
#>   measurement_concept_id unit_concept_id n n_ok n_below n_above n_invalid
#> 1                3004249            8876 3    1       1       1         0
#> 2                3016723            8749 1    1       0       0         0
#>   n_changed n_dropped n_no_limit n_unit_skipped n_insufficient_data
#> 1         2         0          0              0                   0
#> 2         0         0          0              0                   0
DBI::dbDisconnect(con, shutdown = TRUE)