# 이 장에서 사용하는 패키지
library(tidyverse) # dplyr, tidyr, stringr, purrr 등 포함
library(lubridate) # 날짜/시간 처리
library(readxl) # Excel 파일 읽기
library(haven) # SAS, SPSS 파일 읽기4 R 객체 다루기 II: 고급 조작 및 데이터 변환
이전 장에서 dplyr의 5대 동사를 학습했습니다. 이번 장에서는 실제 임상 데이터 분석에서 반드시 필요한 고급 데이터 조작 기법을 배웁니다. 데이터 형태 변환(tidyr), 문자열 처리(stringr), 날짜/시간 처리(lubridate), 데이터 결합(join), 결측치 및 BLQ 처리, 그리고 외부 데이터 읽기까지 다룹니다.
이번 장의 예제에서는 여러 피부과/자가면역질환 치료제의 임상시험 데이터를 활용합니다.
4.1 tidyr: 데이터 형태 변환
4.1.1 Tidy data의 원칙
“Tidy data”란 다음 세 가지 조건을 만족하는 데이터 구조를 말합니다:
- 각 변수(variable)는 하나의 열(column)을 구성한다
- 각 관측값(observation)은 하나의 행(row)을 구성한다
- 각 값(value)은 하나의 셀(cell)에 들어간다
PK 데이터에서는 같은 데이터가 Wide format과 Long format 두 가지 형태로 존재할 수 있으며, 분석 목적에 따라 변환이 필요합니다.
4.1.2 Wide format vs Long format
Wide format (흔히 임상 현장에서 사용하는 형태):
patient_id C0_visit1 C0_visit2 C0_visit3 C2_visit1 C2_visit2
PT001 145.2 162.8 158.1 1025.3 1180.5
PT002 98.3 110.5 NA 850.2 920.1
Long format (분석에 적합한 형태):
patient_id visit sample_type concentration
PT001 1 C0 145.2
PT001 1 C2 1025.3
PT001 2 C0 162.8
PT001 2 C2 1180.5
PT001 3 C0 158.1
PT002 1 C0 98.3
PT002 1 C2 850.2
...
4.1.3 pivot_longer(): Wide → Long 변환
pivot_longer()는 여러 열을 하나의 열로 “길게” 변환합니다. 임상 데이터에서 시간 포인트가 열로 펼쳐져 있는 경우 매우 자주 사용합니다.
# Wide format 예제 데이터
pk_wide <- tibble(
patient_id = c("PT001", "PT002", "PT003", "PT004", "PT005"),
dose_mg = c(150, 200, 175, 150, 200),
conc_0h = c(145.2, 98.3, 178.5, 132.1, 88.6),
conc_2h = c(1025.3, 850.2, 1245.8, 980.4, 720.1),
conc_4h = c(685.2, 520.1, 810.3, 645.7, 480.9),
conc_6h = c(420.1, 310.5, 505.8, 398.2, 285.3),
conc_8h = c(280.5, 198.2, 335.1, 260.8, 178.6),
conc_12h = c(155.3, 105.8, 185.2, 140.5, 92.1)
)
pk_wide# Wide → Long 변환
pk_long <- pk_wide |>
pivot_longer(
cols = starts_with("conc_"), # 변환할 열 지정
names_to = "time_point", # 열 이름이 들어갈 새 열
values_to = "concentration" # 값이 들어갈 새 열
)
pk_long출력 결과:
# A tibble: 30 × 4
patient_id dose_mg time_point concentration
<chr> <dbl> <chr> <dbl>
1 PT001 150 conc_0h 145.2
2 PT001 150 conc_2h 1025.3
3 PT001 150 conc_4h 685.2
4 PT001 150 conc_6h 420.1
5 PT001 150 conc_8h 280.5
6 PT001 150 conc_12h 155.3
...
4.1.3.1 열 이름에서 숫자 추출
변환 후 시간 정보가 문자열(“conc_0h”, “conc_2h” 등)로 남아 있습니다. names_pattern이나 names_transform을 사용하여 숫자를 추출할 수 있습니다:
# 방법 1: names_pattern으로 시간 숫자 추출
pk_long <- pk_wide |>
pivot_longer(
cols = starts_with("conc_"),
names_to = "time_hr",
names_pattern = "conc_(\\d+)h", # 정규표현식으로 숫자 부분만 추출
names_transform = list(time_hr = as.numeric), # 숫자로 변환
values_to = "concentration"
)
# 방법 2: 변환 후 mutate()로 처리
pk_long <- pk_wide |>
pivot_longer(
cols = starts_with("conc_"),
names_to = "time_point",
values_to = "concentration"
) |>
mutate(
time_hr = as.numeric(str_extract(time_point, "\\d+"))
) |>
select(-time_point)
pk_long대부분의 PK 분석 도구(NONMEM, Phoenix WinNonlin 등)와 시각화(ggplot2)에서는 Long format을 요구합니다. 임상 현장에서 받는 데이터가 Wide format인 경우가 많으므로, pivot_longer()는 PK 데이터 전처리의 첫 단계로 매우 자주 사용됩니다.
4.1.4 pivot_wider(): Long → Wide 변환
pivot_wider()는 pivot_longer()의 역연산으로, 행을 열로 “넓게” 펼칩니다. 요약 테이블을 만들거나, C0/C2 비율을 계산할 때 유용합니다.
# Long format 데이터에서 C0와 C2를 별도 열로 만들기
csa_tdm_wide <- csa_tdm |>
select(patient_id, visit, sample_type, concentration) |>
pivot_wider(
names_from = sample_type, # 열 이름으로 사용할 변수
values_from = concentration # 값으로 사용할 변수
)
# 결과: patient_id, visit, C0, C2 열이 생성됨
csa_tdm_wide# C2/C0 비율 계산
csa_tdm_wide <- csa_tdm_wide |>
mutate(c2_c0_ratio = C2 / C0)4.1.4.1 요약 통계 테이블 만들기
# 방문별, 채혈 시점별 요약 통계 → Wide format 테이블
summary_table <- csa_tdm |>
filter(!blq_flag) |>
group_by(visit, sample_type) |>
summarise(
mean_conc = round(mean(concentration, na.rm = TRUE), 1),
sd_conc = round(sd(concentration, na.rm = TRUE), 1),
.groups = "drop"
) |>
mutate(
summary = paste0(mean_conc, " ± ", sd_conc)
) |>
select(visit, sample_type, summary) |>
pivot_wider(
names_from = visit,
values_from = summary,
names_prefix = "Visit "
)
summary_table4.1.5 복잡한 변환: 여러 값을 동시에 변환
실제 데이터에서는 하나의 행에 여러 측정값이 있는 경우가 흔합니다:
# 혈액검사 데이터 (Wide format)
lab_wide <- tibble(
patient_id = c("PT001", "PT002"),
visit1_scr = c(0.82, 1.05),
visit1_alt = c(25, 38),
visit1_ast = c(22, 35),
visit2_scr = c(0.85, 1.12),
visit2_alt = c(28, 42),
visit2_ast = c(24, 40)
)
# 여러 값을 동시에 Long format으로 변환
lab_long <- lab_wide |>
pivot_longer(
cols = -patient_id,
names_to = c("visit", "lab_test"),
names_pattern = "(visit\\d+)_(\\w+)", # 그룹 캡처
values_to = "value"
)
lab_longpivot_wider()사용 시 중복된 조합이 있으면 값이 리스트로 들어갈 수 있습니다.values_fn인자로 집계 함수를 지정하세요.pivot_longer()에서cols인자에 변환할 열을 정확히 지정해야 합니다. 나머지 열은 자동으로 보존됩니다.names_pattern에서 정규표현식 그룹()의 개수와names_to의 길이가 일치해야 합니다.
4.2 문자열 처리 (stringr)
임상 데이터에서 문자열 처리는 피할 수 없는 작업입니다. 환자 정보, 검사 결과, 약물 이름 등에서 다양한 문자열 문제가 발생합니다.
4.2.1 임상 데이터의 흔한 문자열 문제
# 실제로 흔히 접하는 "지저분한" 데이터
messy_data <- tibble(
patient_id = c("PT-001", "pt002", "PT 003", " PT004 ", "PT_005"),
concentration = c("145.2", "< 0.5", "BLQ", "N/A", " 98.3 "),
dose = c("150 mg", "200mg", "175 MG", "150 mg", "200mg"),
date_str = c("2024-01-15", "01/15/2024", "15-Jan-2024",
"2024.01.15", "Jan 15, 2024")
)
messy_data4.2.2 기본 문자열 함수
# 문자열 길이
str_length("Cyclosporine") # 12
# 대소문자 변환
str_to_upper("cyclosporine") # "CYCLOSPORINE"
str_to_lower("CYCLOSPORINE") # "cyclosporine"
str_to_title("cyclosporine a") # "Cyclosporine A"
# 공백 제거 (양쪽)
str_trim(" PT004 ") # "PT004"
str_squish("PT 004") # "PT 004" (내부 공백도 정리)
# 패딩
str_pad("1", width = 3, pad = "0") # "001"4.2.3 패턴 매칭과 추출
# 패턴 감지
str_detect("< 0.5", "^<") # TRUE (BLQ 패턴)
str_detect("145.2", "^<") # FALSE
# 패턴 추출
str_extract("150 mg", "\\d+") # "150"
str_extract("< 0.5", "[\\d.]+") # "0.5"
# 패턴 교체
str_replace("PT-001", "-", "") # "PT001"
str_replace_all("PT-001-A", "-", "") # "PT001A"
# 문자열 분리
str_split("150 mg BID", " ") # list("150", "mg", "BID")4.2.4 임상 데이터 정제 실습
# 환자 ID 통일
clean_data <- messy_data |>
mutate(
# 환자 ID 정리: 공백/하이픈/언더스코어 제거, 대문자 통일
patient_id_clean = patient_id |>
str_trim() |>
str_to_upper() |>
str_replace_all("[\\s\\-_]", "") |>
str_replace("^(PT)(\\d+)$", "PT\\2"), # PT 접두사 통일
# 농도값 처리
is_blq = str_detect(concentration, "^[<]|BLQ|N/?A"),
conc_numeric = case_when(
str_detect(concentration, "^[<]") ~
as.numeric(str_extract(concentration, "[\\d.]+")) / 2, # LOQ/2
str_detect(concentration, "BLQ|N/?A") ~ NA_real_,
TRUE ~ as.numeric(str_trim(concentration))
),
# 용량 추출 (숫자만)
dose_mg = as.numeric(str_extract(str_to_lower(dose), "\\d+"))
)
clean_data |>
select(patient_id, patient_id_clean, concentration, is_blq,
conc_numeric, dose, dose_mg)실제 임상 데이터에서 BLQ(정량한계 미만)는 다양한 방식으로 기록됩니다:
"< 0.5","<0.5","< LOQ","BLQ","BLOQ""N/A","NA",".","ND"(Not Detected)0(0으로 입력된 경우 - BLQ인지 실제 0인지 확인 필요)
데이터를 처리하기 전에 반드시 고유한 값(unique values)을 확인하여 모든 BLQ 표현을 파악해야 합니다:
# 농도 열의 고유한 문자열 패턴 확인
pk_data |>
count(concentration) |>
filter(str_detect(concentration, "[^\\d.]") | concentration == "0")4.2.5 정규표현식(regex) 기초
임상 데이터 정제에서 자주 사용하는 정규표현식 패턴을 정리합니다:
# 정규표현식 기초 패턴
patterns <- tribble(
~pattern, ~description, ~example_match,
"\\d", "숫자 1자리", "0", "1", "9",
"\\d+", "숫자 1자리 이상", "123", "45",
"[\\d.]+", "숫자와 소수점", "3.14", "0.5",
"^<", "< 로 시작", "< 0.5",
"\\w+", "단어 문자", "PT001",
"^PT\\d{3}$", "PT + 숫자3자리 (정확매칭)", "PT001",
"\\s+", "공백 1개 이상", " ", " "
)| 기호 | 의미 | 예시 |
|---|---|---|
^ |
문자열 시작 | ^PT = “PT”로 시작 |
$ |
문자열 끝 | mg$ = “mg”로 끝 |
\\d |
숫자 (0-9) | \\d+ = 하나 이상의 숫자 |
\\w |
단어 문자 (문자, 숫자, _) | \\w+ = 단어 |
\\s |
공백 문자 | \\s+ = 하나 이상의 공백 |
. |
아무 문자 1개 | a.c = “abc”, “a1c” 등 |
* |
0회 이상 반복 | ab*c = “ac”, “abc”, “abbc” |
+ |
1회 이상 반복 | ab+c = “abc”, “abbc” |
? |
0회 또는 1회 | ab?c = “ac”, “abc” |
{n} |
정확히 n회 | \\d{3} = 정확히 3자리 숫자 |
[abc] |
a, b, c 중 하나 | [FMU] = “F”, “M”, “U” 중 하나 |
() |
그룹 캡처 | (\\d+)\\s*(mg) |
\| |
OR | BLQ\|BLOQ\|ND |
4.3 날짜/시간 처리 (lubridate)
PK 분석에서 상대 시간(relative time) 계산은 핵심적인 전처리 작업입니다. 투약 시간을 기준으로 채혈 시간까지의 경과 시간을 계산해야 NCA(Non-Compartmental Analysis)나 모델링이 가능합니다.
4.3.1 날짜/시간 파싱
# 다양한 형식의 날짜 파싱
ymd("2024-01-15") # 2024-01-15
mdy("01/15/2024") # 2024-01-15
dmy("15-Jan-2024") # 2024-01-15
# 날짜 + 시간
ymd_hm("2024-01-15 08:30") # 2024-01-15 08:30:00
ymd_hms("2024-01-15 08:30:15") # 2024-01-15 08:30:15
# parse_date_time: 더 유연한 파싱
parse_date_time("Jan 15, 2024 8:30 AM", orders = "mdy HM p")4.3.2 상대시간 계산
PK 분석에서 가장 중요한 시간 관련 작업은 투약 시간 대비 채혈 시간의 계산입니다:
# 투약 및 채혈 시간 데이터
dosing_sampling <- tibble(
patient_id = rep("PT001", 7),
event_type = c("dose", "sample", "sample", "sample",
"sample", "sample", "sample"),
datetime = ymd_hm(c(
"2024-01-15 08:00", # 투약
"2024-01-15 08:00", # 투약 직전 채혈 (C0)
"2024-01-15 10:00", # 투약 2시간 후 (C2)
"2024-01-15 12:00", # 투약 4시간 후
"2024-01-15 14:00", # 투약 6시간 후
"2024-01-15 16:00", # 투약 8시간 후
"2024-01-15 20:00" # 투약 12시간 후
))
)
# 투약 시간 기준 상대시간 계산
dosing_time <- dosing_sampling |>
filter(event_type == "dose") |>
pull(datetime)
dosing_sampling <- dosing_sampling |>
mutate(
time_from_dose = as.numeric(
difftime(datetime, dosing_time, units = "hours")
)
)
dosing_sampling4.3.3 여러 투약에 대한 상대시간 계산
실제 임상 데이터에서는 환자가 여러 번 투약을 받으므로, 각 채혈 시간을 가장 최근 투약 시간 기준으로 계산해야 합니다:
# 여러 투약/채혈이 포함된 데이터
pk_events <- tibble(
patient_id = "PT001",
event_type = c("dose", "sample", "sample", "dose",
"sample", "sample", "sample"),
datetime = ymd_hm(c(
"2024-01-15 08:00", # 1차 투약
"2024-01-15 10:00", # 채혈 (1차 투약 후 2h)
"2024-01-15 14:00", # 채혈 (1차 투약 후 6h)
"2024-01-15 20:00", # 2차 투약
"2024-01-15 20:00", # 채혈 (2차 투약 직전, C0)
"2024-01-15 22:00", # 채혈 (2차 투약 후 2h)
"2024-01-16 08:00" # 채혈 (2차 투약 후 12h)
))
)
# 각 채혈의 가장 최근 투약 시간 찾기
pk_events <- pk_events |>
mutate(
# 투약 시간을 채우기 (dose 행에만 투약 시간 기록)
dose_time = if_else(event_type == "dose", datetime, NA),
# 아래로 채우기 (fill)
dose_time = as.POSIXct(
zoo::na.locf(as.numeric(dose_time), na.rm = FALSE),
origin = "1970-01-01", tz = "UTC"
),
# 상대시간 계산
tad = as.numeric(difftime(datetime, dose_time, units = "hours"))
)
pk_eventsTAD는 PK 분석에서 가장 기본적이면서 중요한 변수입니다. NONMEM 데이터셋에서는 보통 TAD 또는 TAPD (Time After Previous Dose)라는 이름으로 포함됩니다. 정확한 TAD 계산이 PK 모델링의 품질을 결정합니다.
4.3.4 날짜/시간의 구성 요소 추출
dt <- ymd_hms("2024-01-15 14:30:00")
year(dt) # 2024
month(dt) # 1
day(dt) # 15
hour(dt) # 14
minute(dt) # 30
wday(dt, label = TRUE) # 월 (Monday)
# 날짜 차이 계산
d1 <- ymd("2024-01-15")
d2 <- ymd("2024-02-12")
as.numeric(d2 - d1) # 28일
# 시간 간격
interval(d1, d2) / weeks(1) # 4주
interval(d1, d2) / months(1) # 약 0.9개월4.3.5 다양한 날짜 형식의 통합 처리
임상 데이터에서는 같은 데이터셋 안에서도 날짜 형식이 다른 경우가 있습니다:
# 다양한 형식의 날짜가 혼재된 데이터
mixed_dates <- tibble(
patient_id = paste0("PT", str_pad(1:5, 3, pad = "0")),
date_raw = c("2024-01-15", "01/20/2024", "15-Feb-2024",
"2024.03.01", "Mar 15, 2024")
)
# parse_date_time으로 여러 형식 동시 파싱
mixed_dates <- mixed_dates |>
mutate(
date_parsed = parse_date_time(
date_raw,
orders = c("ymd", "mdy", "dmy", "ymd"),
quiet = TRUE
),
date_clean = as_date(date_parsed)
)
mixed_dates- 시간대(timezone) 문제: 다기관 연구에서 시간대가 다를 수 있습니다.
force_tz()와with_tz()를 구분하세요. - 불완전한 시간 기록: “2024-01-15” (시간 없음), “08:30” (날짜 없음) 등 불완전한 기록이 흔합니다.
- 24시간 vs 12시간 형식: “08:30 PM”과 “20:30”의 혼재에 주의하세요.
- 일광절약시간(DST): 미국/유럽 데이터에서 시간 계산 오류의 원인이 될 수 있습니다.
4.4 데이터 결합 (*_join) {#sec-joins}
실제 PK 분석에서는 여러 소스의 데이터를 결합해야 합니다. 투약 데이터(dosing), 농도 데이터(concentration), 인구통계 데이터(demographics), 검사실 데이터(laboratory) 등이 별도의 파일 또는 테이블로 관리되는 경우가 대부분입니다.
4.4.1 join의 종류
# 예제 데이터 준비
# 인구통계 데이터
demographics <- tibble(
patient_id = c("PT001", "PT002", "PT003", "PT004", "PT005"),
age = c(34, 45, 28, 52, 38),
sex = c("F", "M", "F", "M", "F"),
weight_kg = c(65.2, 78.1, 55.8, 82.3, 60.5),
height_cm = c(162, 175, 158, 180, 165),
bsa_m2 = c(1.69, 1.94, 1.55, 2.02, 1.66)
)
# 농도 데이터 (일부 환자는 demographics에 없을 수 있음)
concentration_data <- tibble(
patient_id = c("PT001", "PT001", "PT002", "PT002",
"PT003", "PT006"), # PT006은 demographics에 없음
visit = c(1, 2, 1, 2, 1, 1),
concentration = c(145.2, 162.8, 98.3, 110.5, 178.5, 125.0)
)
# 투약 데이터
dosing_data <- tibble(
patient_id = c("PT001", "PT002", "PT003", "PT004", "PT005"),
dose_mg = c(150, 200, 175, 150, 200),
frequency = c("BID", "BID", "BID", "BID", "BID")
)4.4.2 left_join: 왼쪽 테이블 기준 결합
# 농도 데이터에 인구통계 정보 추가
conc_with_demo <- concentration_data |>
left_join(demographics, by = "patient_id")
# PT006은 demographics에 없으므로 age, sex 등이 NA가 됨
conc_with_demoleft_join()은 왼쪽 테이블(concentration_data)의 모든 행을 유지하고, 오른쪽 테이블(demographics)에서 매칭되는 정보를 추가합니다. 매칭되지 않는 경우 NA가 됩니다.
4.4.3 inner_join: 양쪽 모두 존재하는 경우만
# 양쪽 모두에 존재하는 환자만 결합
conc_inner <- concentration_data |>
inner_join(demographics, by = "patient_id")
# PT006(demographics에 없음)과 PT004, PT005(concentration에 없음) 제외
conc_inner4.4.4 anti_join: 매칭되지 않는 행 찾기
# concentration_data에는 있지만 demographics에 없는 환자 찾기
missing_demo <- concentration_data |>
anti_join(demographics, by = "patient_id")
# PT006만 반환됨
missing_demo
# demographics에는 있지만 concentration_data에 없는 환자 찾기
no_conc <- demographics |>
anti_join(concentration_data, by = "patient_id")
# PT004, PT005 반환
no_concanti_join()은 두 데이터셋 간의 불일치를 찾는 데 매우 유용합니다. 예를 들어:
- 농도 데이터에 있지만 투약 기록이 없는 환자
- 투약 기록은 있지만 채혈 데이터가 없는 환자
- CRF에 기록되었지만 실험실 결과가 없는 검체
이러한 불일치는 데이터 품질 문제를 시사하며, 분석 전에 반드시 확인해야 합니다.
4.4.5 full_join: 양쪽 모든 행 유지
# 양쪽 모든 행 유지 (매칭되지 않는 부분은 NA)
conc_full <- concentration_data |>
full_join(demographics, by = "patient_id")
conc_full4.4.6 여러 키(key)로 결합
# 환자ID와 방문 번호 모두를 키로 사용
lab_data <- tibble(
patient_id = c("PT001", "PT001", "PT002", "PT002"),
visit = c(1, 2, 1, 2),
scr = c(0.82, 0.85, 1.05, 1.12),
alt = c(25, 28, 38, 42)
)
# 두 개의 키로 결합
concentration_data |>
left_join(lab_data, by = c("patient_id", "visit"))4.4.7 실전: PK 분석용 통합 데이터셋 구축
# 투약 + 농도 + 인구통계 + 검사실 데이터 통합
pk_analysis_data <- concentration_data |>
# 1단계: 투약 정보 추가
left_join(dosing_data, by = "patient_id") |>
# 2단계: 인구통계 추가
left_join(demographics, by = "patient_id") |>
# 3단계: 검사실 데이터 추가 (환자 + 방문 기준)
left_join(lab_data, by = c("patient_id", "visit")) |>
# 4단계: 파생 변수 계산
mutate(
dose_mg_kg = dose_mg / weight_kg,
bmi = weight_kg / (height_cm / 100)^2
)
pk_analysis_data두 테이블을 결합할 때, 키가 양쪽에서 고유하지 않으면 행 수가 예상치 않게 증가할 수 있습니다. 결합 전후의 행 수를 반드시 확인하세요:
nrow_before <- nrow(concentration_data)
result <- concentration_data |>
left_join(some_table, by = "patient_id")
nrow_after <- nrow(result)
if (nrow_after > nrow_before) {
warning("결합 후 행 수 증가: ", nrow_before, " → ", nrow_after)
}4.5 결측치(NA) 및 BLQ 처리
4.5.1 NA 탐지 및 처리
# NA 확인
sum(is.na(csa_tdm$concentration)) # NA 개수
mean(is.na(csa_tdm$concentration)) * 100 # NA 비율 (%)
# 열별 NA 개수 확인
csa_tdm |>
summarise(across(everything(), ~ sum(is.na(.x))))
# 열별 NA 비율 확인
csa_tdm |>
summarise(across(everything(), ~ round(mean(is.na(.x)) * 100, 1)))4.5.1.1 NA 처리 전략
# 전략 1: NA가 있는 행 제거 (Complete Case Analysis)
csa_complete <- csa_tdm |>
filter(!is.na(concentration))
# 전략 2: 특정 값으로 대체
csa_filled <- csa_tdm |>
mutate(
concentration = replace_na(concentration, 0)
)
# 전략 3: 이전/다음 값으로 채우기 (LOCF: Last Observation Carried Forward)
csa_locf <- csa_tdm |>
group_by(patient_id) |>
arrange(visit) |>
mutate(
concentration = if_else(
is.na(concentration),
lag(concentration), # 이전 값으로 대체
concentration
)
) |>
ungroup()
# 전략 4: 그룹 평균으로 대체
csa_mean_impute <- csa_tdm |>
group_by(patient_id, sample_type) |>
mutate(
concentration = if_else(
is.na(concentration),
mean(concentration, na.rm = TRUE),
concentration
)
) |>
ungroup()4.5.2 BLQ (Below Limit of Quantification) 처리
BLQ 데이터의 처리는 약동학 분석에서 매우 중요한 통계적/약리학적 의미를 갖습니다. BLQ 값을 어떻게 처리하느냐에 따라 PK 파라미터 추정치가 달라질 수 있습니다.
BLQ (Below Limit of Quantification)는 분석법(assay)의 정량한계(LOQ, Limit of Quantification) 미만의 농도를 말합니다. 약물이 존재하지만 정확히 정량할 수 없는 농도입니다.
예를 들어, Cyclosporine 분석법의 LOQ가 10 ng/mL이라면, 실제 농도가 5 ng/mL인 경우 “BLQ” 또는 “< 10”으로 보고됩니다.
4.5.2.1 BLQ 처리 방법 (M1-M7)
FDA 및 EMA 가이드라인에서 권장하는 주요 BLQ 처리 방법은 다음과 같습니다:
# BLQ 처리 방법 요약
blq_methods <- tibble(
method = paste0("M", 1:7),
description = c(
"BLQ를 모두 0으로 대체",
"BLQ를 LOQ/2로 대체",
"BLQ를 NA로 처리 (제거)",
"첫 번째 BLQ는 LOQ/2, 이후는 0",
"첫 번째 BLQ는 LOQ/2, 이후는 제거",
"Maximum Likelihood 방법",
"Beal의 M3 방법 (NONMEM)"
),
nca_use = c(
"간단하지만 AUC 과소평가",
"가장 널리 사용, NCA에 적합",
"간단하지만 정보 손실",
"Trough에 BLQ가 많을 때 유용",
"일반적으로 권장",
"모집단 PK에서 사용",
"모집단 PK에서 권장"
),
complexity = c("낮음", "낮음", "낮음", "중간", "중간", "높음", "높음")
)
blq_methods4.5.2.2 BLQ 처리 함수 구현
# BLQ 처리 함수
handle_blq <- function(data, conc_col = "concentration",
blq_col = "blq_flag", loq = 10,
method = "M5") {
data <- data |>
arrange(patient_id, time_hr) # 시간순 정렬 필수
if (method == "M1") {
# M1: BLQ → 0
data |>
mutate(
conc_handled = if_else(.data[[blq_col]], 0, .data[[conc_col]])
)
} else if (method == "M2") {
# M2: BLQ → LOQ/2
data |>
mutate(
conc_handled = if_else(.data[[blq_col]], loq / 2, .data[[conc_col]])
)
} else if (method == "M3") {
# M3: BLQ → NA (제거)
data |>
mutate(
conc_handled = if_else(.data[[blq_col]], NA_real_, .data[[conc_col]])
)
} else if (method == "M5") {
# M5: 첫 번째 BLQ → LOQ/2, 이후 연속 BLQ → 제거
data |>
group_by(patient_id) |>
mutate(
# 연속된 BLQ의 순서 번호 계산
blq_consecutive = if_else(
.data[[blq_col]],
cumsum(.data[[blq_col]]) -
cummax(cumsum(!.data[[blq_col]]) * .data[[blq_col]]),
0L
),
conc_handled = case_when(
!.data[[blq_col]] ~ .data[[conc_col]],
blq_consecutive == 1 ~ loq / 2, # 첫 번째 BLQ
TRUE ~ NA_real_ # 이후 BLQ → NA
)
) |>
ungroup()
} else {
stop("지원하지 않는 방법: ", method, ". M1, M2, M3, M5 중 선택하세요.")
}
}# BLQ 처리 함수 사용 예시
# 테스트 데이터
test_blq <- tibble(
patient_id = rep("PT001", 8),
time_hr = c(0, 1, 2, 4, 8, 12, 16, 24),
concentration = c(0, 850, 620, 310, 120, 45, 8, 3),
blq_flag = c(TRUE, FALSE, FALSE, FALSE, FALSE, FALSE, TRUE, TRUE)
)
# 각 방법으로 처리
test_blq |> handle_blq(loq = 10, method = "M1") |> select(time_hr, conc_handled)
test_blq |> handle_blq(loq = 10, method = "M2") |> select(time_hr, conc_handled)
test_blq |> handle_blq(loq = 10, method = "M3") |> select(time_hr, conc_handled)
test_blq |> handle_blq(loq = 10, method = "M5") |> select(time_hr, conc_handled)- NCA 분석: M5 방법이 일반적으로 권장됩니다. 첫 BLQ는 LOQ/2로, 이후 연속 BLQ는 제거합니다.
- 모집단 PK 모델링: M3 또는 M7 (Beal’s M3) 방법이 권장됩니다. BLQ를 censored observation으로 처리합니다.
- BLQ 비율 확인: BLQ 비율이 10% 이하이면 처리 방법에 따른 결과 차이가 크지 않습니다. 30% 이상이면 방법 선택이 중요합니다.
- 프로토콜/SAP 확인: 임상시험에서는 통계분석계획서(SAP)에 BLQ 처리 방법이 사전 정의되어 있어야 합니다.
4.5.3 NA와 BLQ의 시각화
# 결측 패턴 시각화
library(naniar) # 결측치 시각화 패키지
# 결측 패턴 요약
gg_miss_var(csa_tdm)
# 농도 데이터에서 BLQ와 NA 분포
csa_tdm |>
mutate(
data_status = case_when(
blq_flag ~ "BLQ",
is.na(concentration) ~ "Missing",
TRUE ~ "Quantifiable"
)
) |>
count(sample_type, data_status) |>
ggplot(aes(x = sample_type, y = n, fill = data_status)) +
geom_col(position = "stack") +
labs(
title = "채혈 시점별 데이터 상태 분포",
x = "채혈 시점", y = "관측 수", fill = "데이터 상태"
) +
theme_minimal()4.6 외부 데이터 읽기
4.6.1 CSV 파일 읽기 (readr)
# 기본 CSV 읽기
pk_data <- read_csv("data/cyclosporine_tdm.csv")
# 열 유형 지정
pk_data <- read_csv(
"data/cyclosporine_tdm.csv",
col_types = cols(
patient_id = col_character(),
visit = col_integer(),
date = col_date(format = "%Y-%m-%d"),
concentration = col_double(),
blq_flag = col_logical()
)
)
# 한글 인코딩 문제 대처 (EUC-KR로 작성된 파일)
pk_data_kr <- read_csv(
"data/korean_data.csv",
locale = locale(encoding = "EUC-KR")
)- 인코딩 문제: 한국 병원 시스템에서 추출한 데이터는 EUC-KR 인코딩인 경우가 많습니다. UTF-8로 읽으면 한글이 깨집니다.
- 구분자 문제: 유럽 데이터는 세미콜론(
;)을 구분자로, 쉼표(,)를 소수점으로 사용하는 경우가 있습니다.read_csv2()또는read_delim()을 사용하세요. - 열 유형 자동 추론 실패:
read_csv()의 자동 유형 추론은 첫 1000행을 기반으로 합니다. BLQ 값(“< 0.5”)이 중간에 나타나면 열 유형이 잘못 추론될 수 있습니다.
4.6.2 Excel 파일 읽기 (readxl)
# Excel 파일 읽기
library(readxl)
# 기본 읽기
pk_excel <- read_excel("data/pk_data.xlsx")
# 특정 시트 읽기
pk_sheet2 <- read_excel("data/pk_data.xlsx", sheet = "concentration")
# 범위 지정
pk_range <- read_excel("data/pk_data.xlsx", range = "A1:F100")
# 시트 이름 확인
excel_sheets("data/pk_data.xlsx")# 여러 시트를 한 번에 읽기
sheets <- excel_sheets("data/pk_data.xlsx")
all_sheets <- sheets |>
set_names() |>
map(~ read_excel("data/pk_data.xlsx", sheet = .x))
# 리스트로 반환됨
all_sheets[["demographics"]]
all_sheets[["concentration"]]4.6.3 SAS/SPSS 파일 읽기 (haven)
임상시험 데이터는 SAS(.sas7bdat, .xpt) 또는 SPSS(.sav) 형식으로 제공되는 경우가 많습니다.
library(haven)
# SAS 데이터셋 읽기
adpc <- read_sas("data/adpc.sas7bdat")
# SAS Transport 파일 읽기 (CDISC 제출용)
dm <- read_xpt("data/dm.xpt") # Demographics
ex <- read_xpt("data/ex.xpt") # Exposure (투약)
pc <- read_xpt("data/pc.xpt") # Pharmacokinetics (농도)
# SPSS 파일 읽기
spss_data <- read_sav("data/clinical_data.sav")
# SAS 레이블 확인
dm |>
map_chr(~ attr(.x, "label") %||% "")FDA에 임상시험 데이터를 제출할 때는 CDISC 표준의 SAS Transport (.xpt) 형식을 사용합니다. haven::read_xpt()로 이 파일을 R에서 직접 읽을 수 있습니다. CDISC SDTM과 ADaM 데이터셋의 구조는 이 장 뒤에서 자세히 다룹니다.
4.6.4 데이터 쓰기
# CSV로 저장
write_csv(pk_analysis_data, "output/pk_analysis_data.csv")
# Excel로 저장
library(writexl)
write_xlsx(pk_analysis_data, "output/pk_analysis_data.xlsx")
# 여러 시트를 가진 Excel 저장
write_xlsx(
list(
concentration = conc_data,
demographics = demo_data,
summary = summary_table
),
"output/pk_analysis_workbook.xlsx"
)
# SAS Transport로 저장
write_xpt(pk_analysis_data, "output/adpc.xpt")4.7 약리학 노트: 임상시험 데이터의 구조
임상시험에서 수집되는 데이터는 국제적으로 표준화된 형식을 따릅니다. 이 표준을 이해하면 실제 임상 데이터를 받았을 때 더 효율적으로 분석할 수 있습니다.
4.7.1 CRF (Case Report Form) 구조
CRF는 임상시험에서 환자 데이터를 수집하기 위한 문서(또는 전자 양식)입니다. 현대 임상시험에서는 대부분 eCRF(electronic CRF)를 사용합니다.
PK 연구와 관련된 주요 CRF 페이지:
# CRF 페이지와 수집 데이터 요약
crf_pages <- tibble(
page = c("인구통계학", "병력", "약물 투여", "채혈 기록",
"약동학 결과", "이상반응", "임상검사", "활력징후",
"피부과 평가", "병용약물"),
domain = c("DM", "MH", "EX", "PC",
"PP", "AE", "LB", "VS",
"QS/FA", "CM"),
collected_data = c(
"나이, 성별, 인종, 체중, 신장",
"기저질환, 아토피 피부염 이환 기간",
"투약 날짜/시간, 용량, 투여 경로",
"채혈 날짜/시간, 투약 대비 채혈 시점",
"혈중 농도, PK 파라미터",
"이상반응명, 발생일, 중증도, 인과관계",
"혈액학, 생화학, 소변검사",
"혈압, 맥박, 체온",
"EASI, SCORAD, IGA, BSA, DLQI",
"병용약물명, 용량, 투여 기간"
)
)
crf_pages4.7.2 CDISC SDTM 개요
CDISC(Clinical Data Interchange Standards Consortium)의 SDTM(Study Data Tabulation Model)은 임상시험 데이터의 표준 구조입니다.
# SDTM 주요 도메인
sdtm_domains <- tibble(
class = c(rep("Special Purpose", 3),
rep("Interventions", 3),
rep("Events", 2),
rep("Findings", 4)),
domain = c("DM", "SE", "SV",
"EX", "CM", "EC",
"AE", "MH",
"LB", "VS", "PC", "QS"),
description = c(
"Demographics (인구통계학)",
"Subject Elements (연구 요소)",
"Subject Visits (방문)",
"Exposure (시험약 투여)",
"Concomitant Medications (병용약물)",
"Exposure as Collected (투약 원시 기록)",
"Adverse Events (이상반응)",
"Medical History (병력)",
"Laboratory Test Results (검사실 검사)",
"Vital Signs (활력징후)",
"Pharmacokinetics Concentrations (PK 농도)",
"Questionnaires (설문/평가)"
)
)
sdtm_domains4.7.2.1 PC (Pharmacokinetics Concentrations) 도메인의 구조
# PC 도메인의 주요 변수
pc_variables <- tibble(
variable = c("STUDYID", "DOMAIN", "USUBJID", "PCSEQ",
"PCTEST", "PCTESTCD", "PCORRES", "PCORRESU",
"PCSTRESC", "PCSTRESN", "PCSTRESU",
"PCSTAT", "PCREASND",
"PCDTC", "PCDY",
"PCTPT", "PCTPTNUM", "PCELTM",
"VISIT", "VISITNUM"),
label = c("Study ID", "Domain", "Unique Subject ID", "Sequence Number",
"Test Name", "Test Code", "Original Result", "Original Units",
"Standardized Result (Char)", "Standardized Result (Num)",
"Standardized Units",
"Completion Status", "Reason Not Done",
"Date/Time", "Study Day",
"Planned Timepoint", "Planned Timepoint Number",
"Planned Elapsed Time",
"Visit Name", "Visit Number"),
example = c("STUDY01", "PC", "STUDY01-PT001", "1",
"Cyclosporine", "CSA", "145.2", "ng/mL",
"145.2", "145.2", "ng/mL",
"", "",
"2024-01-15T08:30", "15",
"Pre-dose", "0", "PT0H",
"WEEK 2", "3")
)
pc_variables4.7.3 피부과 임상시험의 특수한 데이터 요소
피부과 임상시험에서는 일반적인 PK 데이터 외에 피부과 특유의 효능 평가 도구가 사용됩니다.
# 피부과 효능 평가 도구
derm_endpoints <- tibble(
endpoint = c("EASI", "SCORAD", "IGA", "BSA", "PASI",
"DLQI", "NRS", "vIGA-AD"),
full_name = c(
"Eczema Area and Severity Index",
"SCORing Atopic Dermatitis",
"Investigator's Global Assessment",
"Body Surface Area (affected)",
"Psoriasis Area and Severity Index",
"Dermatology Life Quality Index",
"Numerical Rating Scale (Pruritus)",
"Validated IGA for Atopic Dermatitis"
),
indication = c(
"아토피 피부염", "아토피 피부염", "아토피 피부염/건선",
"아토피 피부염/건선", "건선",
"피부질환 전반", "아토피 피부염(소양감)", "아토피 피부염"
),
score_range = c(
"0-72", "0-103", "0-4 또는 0-5", "0-100%",
"0-72", "0-30", "0-10", "0-4"
),
pk_pd_relevance = c(
"E-R 분석의 주요 효능 평가변수",
"복합 평가, E-R 분석에 사용",
"이분형(binary) 반응 변수로 활용",
"EASI/PASI와 함께 평가",
"건선 E-R 분석의 핵심 변수",
"환자 보고 결과(PRO)",
"증상 평가, PRO",
"최신 임상시험에서 주요 평가변수"
)
)
derm_endpointsEASI(Eczema Area and Severity Index)는 아토피 피부염의 중증도를 객관적으로 평가하는 도구입니다:
- 평가 부위: 머리/목, 상지, 체간, 하지 (4개 부위)
- 평가 항목: 홍반(erythema), 부종/구진(edema/papulation), 찰상(excoriation), 태선화(lichenification) (4가지)
- 각 항목: 0-3점 척도
- 면적 가중치: 각 부위의 면적 비율에 따라 가중치 적용
- 총점: 0-72점
- 임상시험 반응 기준: EASI-50 (50% 감소), EASI-75 (75% 감소), EASI-90 (90% 감소)
PK-PD 분석에서는 EASI의 기저치 대비 변화율(%)을 주요 효능 평가변수로 사용합니다.
# EASI 점수 데이터 구조 예시
easi_data <- tibble(
patient_id = rep(c("PT001", "PT002"), each = 5),
visit = rep(c("Baseline", "Week 2", "Week 4", "Week 8", "Week 16"), 2),
visit_num = rep(c(0, 2, 4, 8, 16), 2),
easi_total = c(28.5, 22.1, 15.3, 8.2, 5.1, # PT001
35.2, 30.8, 24.5, 18.1, 12.8), # PT002
iga_score = c(4, 3, 3, 2, 1,
4, 4, 3, 3, 2)
)
# EASI 변화율 계산
easi_data <- easi_data |>
group_by(patient_id) |>
mutate(
easi_baseline = first(easi_total),
easi_change_pct = (easi_total - easi_baseline) / easi_baseline * 100,
easi_75 = easi_change_pct <= -75, # EASI-75 달성 여부
easi_50 = easi_change_pct <= -50 # EASI-50 달성 여부
) |>
ungroup()
easi_data4.8 Claude Code 활용 팁
4.8.1 복잡한 데이터 변환을 자연어로 설명
데이터 변환 작업이 복잡할 때, 코드를 직접 작성하기보다 Claude Code에 자연어로 설명하면 효과적입니다.
Wide → Long 변환:
“이 Excel 파일에서 환자별로 Visit 1부터 Visit 8까지의 EASI 점수가 열로 펼쳐져 있습니다. 이것을 patient_id, visit, easi_score 세 개의 열로 된 long format으로 변환하고, visit 열은 숫자(주 단위)로 변환해 주세요.”
여러 데이터 결합:
“다음 세 개의 CSV 파일을 결합하는 R 코드를 작성해 주세요: 1. demographics.csv (patient_id, age, sex, weight) 2. dosing.csv (patient_id, visit, dose_mg, dose_datetime) 3. concentration.csv (patient_id, visit, sample_time, concentration, blq_flag)
patient_id와 visit을 키로 사용하고, 모든 농도 데이터를 유지하되 demographics가 없는 환자는 경고를 출력해 주세요.”
4.8.2 Excel에서 분석 데이터로 변환
임상 현장에서 받는 Excel 파일은 사람이 읽기 편한 형태로 구성되어 있어 분석에 바로 사용할 수 없는 경우가 많습니다. 병합된 셀, 색상으로 표시된 정보, 여러 시트에 분산된 데이터 등이 흔합니다.
“data/clinical_report.xlsx 파일을 R로 읽어서 분석 가능한 형태로 정리해 주세요. 이 파일의 특징은:
- ‘Demographics’ 시트: 1행은 제목, 2행이 열 이름, 3행부터 데이터
- ‘PK Results’ 시트: 시간 포인트(0h, 1h, 2h, 4h, 8h, 12h, 24h)가 열로 펼쳐져 있음
- 농도값에 ‘<10’ 같은 BLQ 표시가 섞여 있음
- 일부 날짜가 Excel 시리얼 넘버로 저장되어 있음
최종적으로 NONMEM에 입력할 수 있는 long format 데이터셋을 만들어 주세요.”
4.8.3 코드 리뷰 및 최적화 요청
작성한 데이터 전처리 코드가 올바른지 검증하고 싶을 때:
“다음 R 코드를 리뷰해 주세요. 특히: 1. 데이터 손실이 발생할 수 있는 부분이 있는지 2. join 시 예상치 못한 행 수 증가가 있을 수 있는지 3. BLQ 처리가 올바른지 4. 더 효율적으로 작성할 수 있는 부분이 있는지 확인해 주세요.”
4.9 연습 문제
4.9.1 확인 문제
pivot_longer()와 pivot_wider()의 차이점을 설명하고, PK 데이터에서 각각 어떤 상황에서 사용하는지 예를 들어 설명하세요.
정답 보기
pivot_longer(): 여러 열을 하나의 열로 합침 (Wide → Long). 시간 포인트가 열로 펼쳐진 PK 데이터를 분석/시각화에 적합한 Long format으로 변환할 때 사용합니다.pivot_wider(): 하나의 열을 여러 열로 펼침 (Long → Wide). C0와 C2가 같은 열에 있는 Long format 데이터에서 C2/C0 비율을 계산할 때, 각각을 별도 열로 만들기 위해 사용합니다.
BLQ 처리 방법 M2와 M5의 차이점은 무엇이며, NCA 분석에서 일반적으로 권장되는 방법은 무엇입니까?
정답 보기
- M2: 모든 BLQ 값을 LOQ/2로 대체합니다.
- M5: 첫 번째 BLQ는 LOQ/2로, 이후 연속되는 BLQ는 제거(NA)합니다.
- NCA에서는 M5가 권장됩니다. 흡수 전(pre-Cmax)의 BLQ는 의미가 다르고(약물이 아직 흡수되지 않음), 소실 후(post-Cmax)의 연속 BLQ는 약물이 거의 소실된 것이므로 다르게 처리해야 합니다.
left_join()과 inner_join()의 차이를 설명하세요. 투약 데이터와 농도 데이터를 결합할 때 어떤 join을 사용해야 하며, 그 이유는 무엇입니까?
정답 보기
left_join(): 왼쪽 테이블의 모든 행을 유지, 매칭되지 않는 오른쪽 값은 NAinner_join(): 양쪽 모두에 존재하는 행만 유지
left_join()을 권장합니다. inner_join()을 사용하면 한쪽에만 있는 데이터가 자동으로 제거되어 데이터 손실을 인지하지 못할 수 있습니다. left_join() 후 NA를 확인하면 불일치를 파악할 수 있습니다.
다음 코드의 문제점을 찾으세요:
pk_data |>
group_by(patient_id) |>
mutate(mean_conc = mean(concentration)) |>
filter(concentration > mean_conc) |>
summarise(n = n())정답 보기
group_by() 후 ungroup()을 호출하지 않았습니다. mutate()와 filter() 단계에서는 그룹화가 유지되므로 의도한 대로 환자별로 작동하지만, summarise() 후에도 마지막 그룹 수준이 남아 있을 수 있습니다. 또한 mean() 계산에서 na.rm = TRUE가 없어 NA가 있으면 mean_conc이 NA가 되어 모든 행이 필터링됩니다.
수정:
pk_data |>
group_by(patient_id) |>
mutate(mean_conc = mean(concentration, na.rm = TRUE)) |>
filter(concentration > mean_conc) |>
summarise(n = n(), .groups = "drop")CDISC SDTM에서 PC 도메인과 PP 도메인의 차이를 설명하세요.
정답 보기
- PC (Pharmacokinetics Concentrations): 약물의 혈중 농도 원시 데이터를 저장합니다. 각 채혈 시점의 개별 농도값, 채혈 시간, BLQ 여부 등이 포함됩니다.
- PP (Pharmacokinetic Parameters): NCA 등으로 계산된 PK 파라미터를 저장합니다. Cmax, Tmax, AUC, t1/2 등의 도출된 파라미터가 포함됩니다.
4.9.2 R 과제
다음 Wide format 데이터를 Long format으로 변환하고, 시간을 숫자형으로 변환하세요. 변환 후 환자별 농도-시간 프로파일을 ggplot2로 시각화하세요.
# Wide format PK 데이터
pk_wide <- tibble(
patient_id = paste0("PT", str_pad(1:10, 3, pad = "0")),
dose_mg = sample(c(100, 150, 200), 10, replace = TRUE),
conc_0h = round(runif(10, 80, 180), 1),
conc_1h = round(runif(10, 400, 900), 1),
conc_2h = round(runif(10, 700, 1300), 1),
conc_4h = round(runif(10, 400, 800), 1),
conc_8h = round(runif(10, 150, 400), 1),
conc_12h = round(runif(10, 80, 200), 1)
)힌트 보기
pk_long <- pk_wide |>
pivot_longer(
cols = starts_with("conc_"),
names_to = "time_hr",
names_pattern = "conc_(\\d+)h",
names_transform = list(time_hr = ___),
values_to = "concentration"
)
ggplot(pk_long, aes(x = ___, y = ___, group = ___, color = ___)) +
geom___() +
geom___() +
scale_y_log10() +
labs(x = "시간 (hr)", y = "농도 (ng/mL)") +
theme_minimal()다음 “지저분한” 데이터를 정제하고, 통합된 분석 데이터셋을 만드세요:
# 지저분한 데이터들
demo_messy <- tibble(
subj = c("PT-001", "pt_002", " PT003", "PT 004 ", "PT.005"),
AGE = c(34, 45, 28, 52, 38),
Gender = c("female", "MALE", "F", "m", "Female"),
WT = c("65.2 kg", "78.1", "55.8kg", "82.3 KG", "60.5")
)
conc_messy <- tibble(
ID = c("PT001", "PT001", "PT002", "PT002", "PT003"),
VISIT = c("Visit 1", "VISIT 2", "visit1", "Visit-2", "V1"),
CONC = c("145.2", "<10", "98.3", "BLQ", "N/A"),
TIME = c("Pre-dose", "2hr post", "pre dose", "2h", "0h")
)과제: 1. 환자 ID를 “PTXXX” 형식으로 통일 2. 성별을 “M”/“F”로 통일 3. 체중에서 숫자만 추출 4. 농도에서 BLQ 식별 및 처리 (LOQ=10, M2 방법) 5. 방문 번호를 숫자로 추출 6. 두 데이터를 환자 ID로 결합
힌트 보기
str_to_upper(), str_replace_all(), str_extract(), str_detect(), case_when(), left_join()을 활용하세요.
다음 데이터에서 각 채혈 시점의 TAD(Time After Dose)를 계산하고, BLQ를 M5 방법으로 처리하세요:
# 투약/채혈 데이터
pk_raw <- tibble(
patient_id = rep("PT001", 14),
event = c("dose", "sample", "sample", "sample", "sample",
"sample", "sample", "dose", "sample", "sample",
"sample", "sample", "sample", "sample"),
datetime = ymd_hm(c(
"2024-01-15 08:00", # 1차 투약
"2024-01-15 07:55", # pre-dose
"2024-01-15 10:00", # 2h
"2024-01-15 12:00", # 4h
"2024-01-15 16:00", # 8h
"2024-01-15 20:00", # 12h (trough)
"2024-01-15 19:55", # pre-dose (2차)
"2024-01-15 20:00", # 2차 투약
"2024-01-15 22:00", # 2h
"2024-01-16 00:00", # 4h
"2024-01-16 04:00", # 8h
"2024-01-16 08:00", # 12h
"2024-01-16 14:00", # 18h
"2024-01-16 20:00" # 24h
)),
concentration = c(NA, 5, 850, 620, 310, 120, 118, NA,
780, 550, 180, 85, 8, 3),
blq_flag = c(NA, TRUE, FALSE, FALSE, FALSE, FALSE, FALSE, NA,
FALSE, FALSE, FALSE, FALSE, TRUE, TRUE)
)과제: 1. 각 채혈(sample) 시점에 대해 가장 최근 투약 시간 기준 TAD 계산 2. BLQ를 M5 방법으로 처리 (LOQ = 10 ng/mL) 3. 최종 데이터에서 dose 행 제외, TAD와 처리된 농도만 포함
힌트 보기
투약 시간을fill() 함수로 아래로 채운 후 TAD를 계산하세요. BLQ 처리 시 Cmax 이전과 이후를 구분하면 더 정확합니다.
4.9.3 Claude Code 과제
Claude Code에 다음과 같이 요청하세요:
“임상시험에서 받은 Excel 파일을 R로 정리하는 함수를 작성해 주세요. 함수의 입력: Excel 파일 경로 함수의 출력: 정리된 tibble
처리해야 할 사항: 1. 첫 번째 시트에서 demographics, 두 번째 시트에서 PK concentration 읽기 2. 환자 ID 형식 통일 (다양한 구분자 제거) 3. 날짜 형식 통일 (여러 형식 자동 감지) 4. 농도값에서 BLQ 자동 감지 및 M5 방법으로 처리 5. 단위 불일치 자동 감지 및 경고 6. 두 시트를 환자 ID로 결합 7. 불일치 항목 보고서 (anti_join 활용)
에러 처리와 경고 메시지도 포함해 주세요.”
생성된 코드를 직접 실행하여 결과를 확인하고, 필요한 부분을 수정해 보세요. 특히 BLQ 감지 로직이 다양한 BLQ 표현(“< 0.5”, “BLQ”, “BLOQ”, “ND”, “<LOQ” 등)을 모두 처리하는지 확인하세요.
4.10 이 장의 요약
이 장에서 학습한 핵심 내용을 정리합니다:
tidyr의 pivot 함수를 사용하여 Wide ↔︎ Long format 변환을 수행합니다. PK 분석에서는 Long format이 기본입니다.
stringr을 사용한 문자열 처리로 임상 데이터의 일관성 없는 값(BLQ 표기, ID 형식, 단위 등)을 정리합니다.
lubridate로 날짜/시간을 처리하여 TAD(Time After Dose) 등 PK 분석에 필수적인 상대시간을 계산합니다.
join 함수를 사용하여 여러 소스의 데이터(투약, 농도, 인구통계, 검사실)를 통합합니다.
anti_join()으로 불일치를 확인하는 것이 중요합니다.BLQ 처리는 PK 분석의 중요한 전처리 단계이며, NCA에서는 M5 방법이, 모집단 PK에서는 M3/M7 방법이 권장됩니다.
외부 데이터 읽기(readxl, haven)로 Excel, SAS, SPSS 형식의 임상 데이터를 R로 가져올 수 있습니다.
CDISC SDTM은 임상시험 데이터의 국제 표준이며, PC 도메인이 PK 농도 데이터의 표준 구조입니다.
다음 장에서는 이렇게 정리된 데이터를 바탕으로 실제 PK 정보를 추출하고, 비구획분석(NCA)을 수행하는 방법을 학습합니다.