제4장

제4장: API와 도구: TypeScript SDK·CLI·REST

@wia/bio-004 TypeScript SDK, wia-bio-004 명령행 도구, 그리고 명세에 정의된 REST 레지스트리가 1.0판에서 실제로 무엇을 하는지, 어떻게 올바르게 호출하는지, 그리고 표준 본문과 어디서 어긋나는지를 다룬다.

제2장과 제3장은 바이오마커 성능 주장이 무엇을 뜻하는지, 그리고 결과를 어떻게 기록하는지를 정의하였다. 이 장은 WIA-BIO-004와 함께 배포되는 소프트웨어를 다룬다. 저장소에는 세 가지가 들어 있다. TypeScript 라이브러리(api/typescript), Bash 명령행 도구(cli/wia-bio-004.sh), 그리고 두 번째 단계 문서에 기술된 바이오마커 레지스트리용 REST·Python 인터페이스이다. 세 가지의 성숙도는 같지 않다. TypeScript SDK는 실제 계산을 수행하므로 개발의 토대로 삼을 수 있다. 명령행 도구는 1.0판에서 스크립트에 고정된 숫자를 출력하는 시연용 껍데기이다. REST 인터페이스는 참조 서버가 없는 명세이다.

이 장은 소개 문구가 아니라 코드를 읽는다. 아래에 기술한 동작은 모두 1.0판 소스와 대조하여 확인하였고, 실습 예제의 숫자는 모두 제시한 데이터로 그 소스를 실제 실행하여 얻은 것이다. SDK가 명세(§8.2–§8.3)나 README와 다른 곳은 그 차이를 명시하고 안전한 작업 방법을 제안한다. 이 제안은 이 책의 권고이며 표준의 규범적 요구사항이 아니다.

4.1 패키지 구성

패키지에 들어 있는 것

패키지 이름은 @wia/bio-004, 버전은 1.0.0, 라이선스는 MIT이며 런타임 의존성은 없다. package.json은 Node.js 16 이상을 대상으로 하고, tsup으로 CommonJS(dist/index.js)와 ES 모듈(dist/index.mjs) 두 형식과 선언 파일을 함께 빌드한다. 두 번째 진입점인 @wia/bio-004/types는 형식 정의, 상수 객체, 오류 클래스만 따로 내보내므로 레코드의 모양만 필요한 프런트엔드에서 쓰기 편하다. package.json은 공개 레지스트리 배포용으로 설정되어 있으나, 특정 버전이 실제로 그곳에 올라 있는지는 가정하지 말고 설치 시점에 확인해야 한다. 저장소를 복제해 설치할 수도 있는데, 이때는 install.sh가 SDK를 그 자리에서 빌드한다.

공개 인터페이스는 작다. 클래스는 BiomarkerDataSDK 하나이며 기본 내보내기(default export)이기도 하다. 독립 함수 네 개, 즉 calculateROC, validateBiomarker, analyzeSensitivity, predictOutcome는 호출할 때마다 새 인스턴스를 만들어 그 메서드에 위임한다. 클래스에는 이 밖에 generateReport와 문자열 '1.0.0'을 돌려주는 getVersion()이 있으며, 둘 다 독립 함수 형태는 없다. types.ts의 모든 항목은 주 진입점에서도 다시 내보내므로 ROCRequest 같은 인터페이스와 BIOMARKER_CONSTANTS 같은 값은 어느 경로에서든 가져올 수 있다.

// ES 모듈: 개별 함수, 클래스(기본 내보내기 겸용), 오류 형식
import BiomarkerDataSDK, { calculateROC, validateBiomarker, BiomarkerError } from '@wia/bio-004';
// 형식 전용 하위 경로
import type { ROCResult, ValidationResult } from '@wia/bio-004/types';

// 또는 CommonJS 파일에서(dist/index.js 로 해석됨):
const { analyzeSensitivity, BIOMARKER_CONSTANTS } = require('@wia/bio-004');

const sdk = new BiomarkerDataSDK();
sdk.getVersion();   // '1.0.0'

사용 전에 고쳐야 할 빌드 결함

1.0판 소스는 배포된 그대로는 컴파일되지 않는다. 두 파일 모두 끝부분에 이미 export로 선언한 이름을 다시 내보내는 중복 목록이 있다. index.ts는 export { BiomarkerDataSDK }를 반복하고, types.ts는 export type { … } 블록과 export { BIOMARKER_CONSTANTS, BiomarkerErrorCode, BiomarkerError } 줄로 끝난다. TypeScript 컴파일러(패키지는 ^5.3.3을 선언하며, 이 책은 5.9에서 오류를 재현하였다. 제8장 참조)는 이를 TS2323("Cannot redeclare exported variable")과 TS2484("Export declaration conflicts with exported declaration") 오류로 거부하고, Node.js는 "Duplicate export"로 모듈 적재를 거부한다. 끝부분의 중복 내보내기 목록을 지우는 것으로 충분하다. 그렇게 고치면 라이브러리가 --strict에서 형식 검사를 통과하고 이 장의 예제가 모두 실행된다. SDK를 도입하는 팀은 이 수정을 적용하거나 수정판을 기다려야 하며, 같은 종류의 퇴행을 즉시 잡을 수 있도록 빌드에 npm run typecheck 단계를 넣어야 한다.

표 4-1. 1.0판 SDK의 공개 메서드
메서드입력 형식출력 형식예외
calculateROCROCRequestROCResult길이 불일치 또는 한 집단만 있을 때 B002, 표본 100건 미만이면 B001
validateBiomarkerBiomarkerValidationValidationResult내부 calculateROC가 던지는 예외(최소 한 번은 반드시 호출됨)
analyzeSensitivitySensitivityAnalysisParamsSensitivityAnalysisResultcalculateROC와 같음(내부에서 항상 호출됨)
predictOutcomeOutcomePredictionPredictionResult없음. 빠진 입력은 0이 된다
generateReport(클래스 전용)Biomarker, ValidationResult, 선택적 임상 데이터BiomarkerReport없음
getVersion(클래스 전용)없음string없음

4.2 calculateROC: 판정 라벨과 측정값에서 곡선까지

입력

ROCRequest는 나란한 배열 두 개와 선택 항목 세 개로 이루어진다. trueLabels는 대상자별 참조 표준 판정(질환 1, 건강 0)이고, predictions는 같은 위치의 같은 대상자에 대한 바이오마커 측정값 또는 모형 점수이다. 선택 항목인 thresholds는 평가할 절단값을 호출자가 지정하는 배열이고, calculateCI(기본값 true)는 AUC 신뢰구간 계산 여부, confidenceLevel은 신뢰수준(기본값 0.95)이다. 요청 형식은 명세 §8.2.1의 것과 같고 신뢰구간 관련 두 항목이 추가되어 있다.

계산에 앞서 함수는 정해진 순서로 세 가지를 검사한다. 두 배열의 길이가 다르면 코드 B002의 BiomarkerError를 던진다. 항목이 100개 미만이면 B001을 던진다. 어느 한 집단이 없으면 다시 B002를 던진다. 집단 수를 셀 때 1과 0을 엄격 비교한다는 점에 주의해야 한다. 라벨이 2나 true이면 어느 집단에도 들어가지 않고 혼동행렬 계산에서도 빠지지만, 100건 최소 표본 수에는 포함된다. 따라서 호출 전에 라벨을 정수 0과 1로 정규화해야 한다.

임곗값 격자

임곗값을 주지 않으면 SDK는 관측값으로 격자를 만든다. 서로 다른 예측값을 정렬한 뒤 k번째마다 하나씩 남기는데, k = max(1, ⌊U/100⌋)이고 U는 서로 다른 값의 개수이다. 서로 다른 값이 200개 미만이면 관측값 전부가 임곗값이 되고, 그 이상이면 격자가 대략 100~200개 점으로 솎아진다. 가장 작은 값은 항상 남는다. 솎는 과정에서 가장 큰 값이 빠지면 그 값에 0.001을 더한 임곗값을 끝에 붙여 곡선이 (0, 0)에 닿게 한다. 소스의 주석은 양끝을 ±0.001로 보탠다고 적고 있지만, 실제로 보태지는 것은 위쪽 끝뿐이다.

각 임곗값 t에서 value ≥ t이면 양성으로 분류한다. 이 규칙이 검사의 방향을 고정한다. 즉 값이 클수록 질환을 뜻해야 한다. 질환에서 값이 낮아지는 단백질처럼 낮은 값이 이상인 표지자는 호출 전에 값의 부호를 바꾸거나 역수를 취해야 하며, 보고된 절단값을 원래 척도로 되돌리는 것도 잊지 말아야 한다.

곡선, AUC, 최적점

임곗값마다 threshold, sensitivity, specificity, fpr, tpr, youdenIndex를 담은 ROCPoint가 하나씩 생긴다. 이 값들은 0과 1 사이의 비율이며, validateBiomarker가 돌려주는 백분율(4.3절)과 다르다. 두 규약을 한 대시보드에 섞어 쓰는 것이 이 SDK를 쓸 때 가장 흔한 표시 오류이다.

AUC는 점들을 거짓양성률 순으로 정렬한 뒤 사다리꼴 공식으로 구한 면적이다. optimalThreshold는 유든 지수 J = 민감도 + 특이도 − 1이 가장 큰 점이다. 비교가 엄격한 '보다 큼'이므로 동점이면 임곗값 오름차순에서 먼저 나온 점이 선택된다. 유든 규칙이 언제 적절한지는 제2장이 설명한다. 이 함수에는 다른 규칙이 없지만, analyzeSensitivity(4.4절)는 여러 규칙의 결과를 함께 보고한다.

신뢰구간

calculateCI가 참이면 결과에 aucCI95가 들어간다. Q1 = AUC/(2 − AUC), Q2 = 2·AUC²/(1 + AUC)를 쓰는 Hanley–McNeil 표준오차로 계산하고 [0, 1] 범위로 자른다. 코드 주석은 이를 "DeLong method approximation"이라 부르지만 사실이 아니다. DeLong 방법은 위치값(placement value)에 기반한 비모수 추정법으로, 상관된 두 AUC를 비교할 때 쓰는 올바른 도구이며(제2장), SDK는 이를 구현하지 않았다.

숫자를 바꾸는 세 가지 함정

거짓양성률이 같은 점들이 AUC를 편향시킨다. 사다리꼴 적분 전의 정렬은 FPR만 기준으로 한다. 두 대조군 값 사이에 환자군 값이 연달아 놓이면 여러 임곗값의 FPR이 같아지는데, 이때 점들은 임곗값 순서를 유지하므로 동점 구간 안에서 참양성률이 늘지 않고 줄어드는 순서가 된다. 그러면 사다리꼴이 각 수직 계단의 엉뚱한 모서리를 잇는다. 4.3절의 120명 데이터에서 SDK는 AUC = 0.8904를 보고하지만, 같은 데이터의 정확한 Mann–Whitney AUC와 같은 점들을 FPR, 다음 TPR 순으로 정렬해 적분한 값은 모두 0.8951이다. 이 예에서는 오차가 작지만 크기는 데이터에 따라 달라진다. 공표할 수치라면 정확한 AUC를 따로 계산하거나, 반환된 rocCurve를 (FPR, TPR) 순으로 다시 정렬해 직접 적분해야 한다.

호출자가 준 임곗값은 곡선을 잘라낸다. thresholds를 주면 곡선에는 그 점들만 들어간다. (0, 0)이나 (1, 1) 기준점이 추가되지 않으므로 선택한 절단값 범위 밖의 면적은 그냥 사라진다. 같은 데이터에서 임곗값 [20, 30, 40, 50]을 주면 AUC가 0.500으로 나오는데, 자동 격자로는 0.890이다. 가장 낮은 임곗값에서도 대조군의 3분의 1이 음성으로 남고, FPR = 0인 두 절단값이 잘못된 순서로 놓이기 때문이다. 지정 임곗값은 동작점을 살펴보는 데만 쓰고 AUC를 보고하는 데는 절대 쓰지 말아야 한다.

신뢰수준은 두 값뿐이다. z 값은 confidenceLevel이 정확히 0.95일 때 1.96이고 그 밖의 모든 값에서 2.576이다. 따라서 90 % 구간을 요청하면 경고 없이 99 % 구간이 돌아온다(예제 데이터에서 95 % 구간 [0.830, 0.950]보다 오히려 넓은 [0.812, 0.969]). 안전한 입력은 0.95와 0.99뿐이다. 또한 결과 필드 이름은 요청한 수준과 무관하게 aucCI95이다.

§8.2.1과의 비교

SDK의 ROCResult는 명세의 ROCResponse와 일치하되, 점마다 youdenIndex를, 결과에 nDiseased와 nHealthy를 더하고 aucCI95를 선택 항목으로 둔다. 소비 측은 추가 필드를 받아들여야 하며, calculateCI: false로 보냈을 때 구간이 있다고 가정해서는 안 된다.

4.3 validateBiomarker: 검증 판정

계산하는 내용

validateBiomarker는 BiomarkerValidation을 받는다. 여기에는 biomarkerType, 각각 value, unit, 불리언 diseaseStatus, 선택적 patientID를 가진 measurements 배열, referenceRange, 선택적 cutoffThreshold, 선택적 prevalence(기본값 0.5)가 들어간다. 반환값 ValidationResult에는 판정 isValid, performance 블록, 전체 rocAnalysis, recommendedCutoff, clinicalUtility 등급, 그리고 warnings, errors, sampleSizeAdequate가 있다.

절단값은 호출자가 cutoffThreshold를 주면 그 값이고, 주지 않으면 내부 ROC 분석의 유든 최적점이며 이 선택은 경고로 기록된다. performance 블록은 그 절단값에서 계산한 DiagnosticPerformance로, 민감도·특이도·정확도, 혼동행렬, PPV·NPV, 두 우도비를 담는다. 임상 유용성 등급은 AUC와 CLINICAL_UTILITY_THRESHOLDS 표로 정한다. 0.90 이상은 excellent, 0.80 이상은 good, 0.70 이상은 fair, 0.60 이상은 poor, 그 아래는 insufficient이다. 민감도나 특이도가 80 % 미만이거나 AUC가 MIN_AUC_CLINICAL(0.70) 미만이면 경고가 붙는다. 오류가 없고 등급이 insufficient가 아니면 유효로 판정하므로, poor 등급 표지자도 유효로 보고된다.

입력 중 두 필드는 받기만 하고 쓰지 않는다. biomarkerType은 어떤 계산에도 영향을 주지 않고, referenceRange는 측정값과 비교되지도 단위를 검사받지도 않는다. 측정값끼리의 단위도 비교하지 않는다. unit은 절단값 경고 문구에 첫 측정값의 단위를 붙이는 데만 쓰인다. 단위 통일(제3장 3.8절)은 호출자의 몫이다.

백분율, 그리고 유병률에서 나오는 PPV

performance의 모든 비율은 백분율(65는 65 %)이고, 우도비는 일반 비이며, prevalence는 비율이다. 더 중요한 것은 예측도를 만드는 방식이다. SDK는 PPV를 TP/(TP + FP)로 계산하지 않는다. prevalence 인수로 베이즈 규칙을 적용한다.

PPV = Sens·p / (Sens·p + (1 − Spec)·(1 − p)),   NPV = Spec·(1 − p) / (Spec·(1 − p) + (1 − Sens)·p)

기본값 p = 0.5에서는 연구 자체가 환자군과 대조군이 같은 수로 균형을 이룰 때만 결과가 건수 기반 값과 같아진다. 불균형 연구에서 기본값을 그대로 두면 연구 집단도, 실제 임상 환경도 설명하지 못하는 예측도를 받게 된다. 규칙은 간단하다. 언제나 사용 목적 집단의 유병률을 넘기고, 모든 보고서에서 PPV·NPV 옆에 그 유병률을 적는다. 연구 자체의 건수 기반 값이 필요하면 confusionMatrix에서 직접 계산한다.

100건 하한, 그리고 오류가 반환되지 않는 경우

이 함수는 문제를 errors에 모아 반환하도록 설계되었고, 실제로 측정값이 100건 미만이거나 한 집단이 없으면 오류를 추가한다. 그러나 이 경우 그 배열은 결코 호출자에게 보이지 않는다. 함수는 내부에서 calculateROC를 항상 호출하는데(절단값이 없으면 그것을 찾으려고 한 번, rocAnalysis를 위해 또 한 번), calculateROC가 먼저 B001이나 B002를 던지기 때문이다. cutoffThreshold를 명시해도 측정값 50건이면 errors가 채워진 결과가 아니라 예외가 발생한다. README의 7건짜리 예제가 실행되지 않는 이유도 이것이다. 그 예제는 B001을 던진다. 같은 이유로 1.0판에서 실제로 반환되는 결과의 sampleSizeAdequate는 항상 true이다.

하한 자체도 정확히 적어 둘 필요가 있다. SDK 상수 MIN_VALIDATION_SAMPLES = 100은 측정값 전체를 세므로 환자군 90건과 대조군 10건도 통과한다. README의 검증 요구사항은 초기 검증에 환자군 100건 그리고 대조군 100건 이상을 요구한다. 두 규칙은 다르다. 연구 계획은 README의 규칙을 충족해야 하며, 연구 규모를 제대로 정하는 방법은 제6장에서 다룬다. SDK가 예외를 던지기 전에 문제를 모두 모으는 작은 사전 검사를 두면 동작을 예측할 수 있다.

import { BIOMARKER_CONSTANTS, BiomarkerValidation } from '@wia/bio-004';

/** SDK 가 예외를 던지기 전에 문제를 모두 모은다. */
export function precheck(input: BiomarkerValidation): string[] {
  const problems: string[] = [];
  const ms = input.measurements;
  if (ms.length < BIOMARKER_CONSTANTS.MIN_VALIDATION_SAMPLES) {
    problems.push(`B001: ${ms.length} measurements, need >= ${BIOMARKER_CONSTANTS.MIN_VALIDATION_SAMPLES}`);
  }
  if (ms.some((m) => typeof m.diseaseStatus !== 'boolean')) {
    problems.push('B002: diseaseStatus must be a boolean');
  }
  const cases = ms.filter((m) => m.diseaseStatus === true).length;
  if (cases === 0 || cases === ms.length) problems.push('B002: need both cases and controls');
  const units = new Set(ms.map((m) => m.unit));
  if (units.size !== 1 || !units.has(input.referenceRange.unit)) {
    problems.push(`B008: mixed or mismatched units: ${[...units].join(', ')}`);
  }
  if (ms.some((m) => !Number.isFinite(m.value))) problems.push('B002: non-finite value');
  return problems;
}

그대로 실행되는 전체 예제

다음 프로그램은 가상의 혈청 표지자에 대한 합성 측정값 120건을 쓴다. 독자가 출력을 정확히 재현할 수 있도록 난수 대신 결정적 공식으로 값을 만들었으며, 어떤 실제 분석물도 나타내지 않는다. 프로그램은 세 분석 함수를 차례로 실행한다.

import {
  calculateROC,
  validateBiomarker,
  analyzeSensitivity,
  BiomarkerError,
  BiomarkerMeasurement,
} from '@wia/bio-004';

// 가상의 혈청 표지자 "MARKER-X"(ng/mL)에 대한 합성 결과 120건:
// 대조군 60건은 10.0-39.5, 환자군 60건은 25.0-60.4 에 분포한다.
// 생성식이 결정적이므로 아래 출력은 그대로 재현된다.
const measurements: BiomarkerMeasurement[] = [];
for (let i = 0; i < 60; i++) {
  measurements.push({ id: `C${i}`, biomarkerID: 'MARKER-X', unit: 'ng/mL',
                      value: 10 + ((i * 37) % 60) * 0.5, diseaseStatus: false });
  measurements.push({ id: `D${i}`, biomarkerID: 'MARKER-X', unit: 'ng/mL',
                      value: 25 + ((i * 23) % 60) * 0.6, diseaseStatus: true });
}

const trueLabels = measurements.map((m) => (m.diseaseStatus === true ? 1 : 0));
const predictions = measurements.map((m) => m.value);

try {
  // 1. 자동 임곗값 격자와 95 % 신뢰구간으로 ROC 계산
  const roc = calculateROC({ trueLabels, predictions });
  console.log(roc.auc, roc.aucCI95, roc.optimalThreshold);

  // 2. 유든 절단값에서 검증, PPV/NPV는 가정 유병률 0.5 기준
  const v = validateBiomarker({
    biomarkerType: 'protein',
    measurements: measurements.map((m) => ({
      value: m.value, unit: m.unit, diseaseStatus: m.diseaseStatus === true,
    })),
    referenceRange: { min: 0, max: 30, unit: 'ng/mL' },
    prevalence: 0.5,
  });
  console.log(v.clinicalUtility, v.recommendedCutoff, v.performance, v.warnings);

  // 3. 임곗값 스윕과 유병률 시나리오
  const s = analyzeSensitivity({ measurements });
  console.log(s.optimalThresholds, s.prevalenceImpact);
} catch (e) {
  if (e instanceof BiomarkerError) console.error(e.code, e.message);
  else throw e;
}

1.0판 SDK(4.1절의 내보내기 수정 적용)로 실행한 결과는 다음과 같다. 읽기 쉽도록 반올림하였다.

calculateROC
  auc                0.890   aucCI95 [0.830, 0.950]
  optimalThreshold   value 37.6   sensitivity 0.650   specificity 0.933   youdenIndex 0.583
  rocCurve           115 points, thresholds 10.0 ... 60.4
validateBiomarker    (prevalence 0.5)
  clinicalUtility    'good'        recommendedCutoff 37.6
  sensitivity 65.0   specificity 93.3   accuracy 79.2          (percent)
  ppv 90.7           npv 72.7           lrPositive 9.75   lrNegative 0.375
  confusionMatrix    tp 39   fn 21   tn 56   fp 4
  warnings           'Using optimal threshold from ROC: 37.60 ng/mL'
                     'Low sensitivity: 65.0% (target: ≥80%)'
analyzeSensitivity
  optimalThresholds  maxYouden 37.6   maxAccuracy 37.6   sensitivity90 28.6   specificity90 37
  prevalenceImpact   0.1: ppv 52.0 / npv 96.0     0.2: ppv 70.9 / npv 91.4
                     0.5: ppv 90.7 / npv 72.7     0.7: ppv 95.8 / npv 53.3
                     0.9: ppv 98.9 / npv 22.9

37.6 ng/mL의 유든 최적점은 민감도(65.0 %)보다 특이도(93.3 %)를 우선한다. SDK는 민감도가 목표 80 %에 못 미친다고 경고하면서도 등급은 AUC만으로 정하므로 good을 준다. PPV 90.7 %가 건수 기반 값 39/43과 같은 것은 이 연구의 환자군과 대조군 수가 정확히 같기 때문일 뿐이다. 사용 목적 유병률이 10 %라면 같은 절단값의 PPV는 52 %이며, 이것이 유병률 낮은 환경의 임상의가 실제로 겪게 될 수치이다. 그리고 AUC 0.890은 SDK의 값이고 이 데이터의 정확한 값은 4.2절에서 설명한 이유로 0.895이다.

§8.2.2와의 비교

명세는 sensitivity, specificity, ppv, npv, accuracy, confusionMatrix를 최상위에 둔 평평한 ValidationResult와 insufficient 없는 4단계 clinicalUtility를 제시한다. SDK는 이 지표들을 performance 아래에 두고 isValid, rocAnalysis, warnings, errors, sampleSizeAdequate와 다섯 번째 등급을 더한다. README처럼 validation.sensitivity를 읽는 코드는 undefined를 받는다. 올바른 경로는 validation.performance.sensitivity이다. 명세에 맞춰 작성된 시스템과 결과를 주고받을 때는 SDK 결과를 명시적으로 평평하게 펴고, insufficient는 합의에 따라 poor 또는 거부로 대응시킨다.

4.4 analyzeSensitivity와 predictOutcome

임곗값 스윕과 유병률 시나리오

analyzeSensitivity는 완전한 BiomarkerMeasurement 레코드(제3장의 형식, id와 biomarkerID 포함), 선택적 thresholdRange({min, max, step}), 선택적 prevalenceScenarios(기본값 0.1, 0.2, 0.5, 0.7, 0.9)를 받는다. 반환값은 세 가지이다. thresholdAnalysis는 각 임곗값(단계 범위, 또는 기본적으로 ROC 격자)에서의 민감도·특이도·PPV·NPV·정확도(모두 백분율)를 나열한다. prevalenceImpact는 유병률 시나리오마다 유든 절단값에서의 PPV와 NPV를 준다. optimalThresholds는 절단값 네 개를 보고한다. maxYouden, maxAccuracy, sensitivity90(민감도 90 % 이상을 유지하는 임곗값 가운데 특이도가 가장 높은 것), specificity90(특이도 90 % 이상을 유지하는 임곗값 가운데 민감도가 가장 높은 것)이다.

이 네 절단값이 제2장 2.5절의 질문, 즉 어떤 규칙을 쓸 것인가에 대한 실무적 답이다. 예제에서 선별 검사 용도라면 sensitivity90 절단값 28.6 ng/mL(민감도 90.0 %, 특이도 63.3 %)을, 확진(rule-in) 용도라면 specificity90 절단값 37.0 ng/mL(민감도 66.7 %, 특이도 90.0 %)을 고르게 되고, 유든의 균형 규칙은 37.6 ng/mL에 놓인다. 어떤 규칙으로 절단값을 정했는지 기록하는 것도 데이터의 일부이다(제3장).

세 가지 세부 사항이 중요하다. thresholdAnalysis의 PPV와 NPV는 시나리오와 무관하게 항상 유병률 0.5로 계산되며, 유병률을 바꾸는 것은 prevalenceImpact뿐이다. 90 % 목표를 만족하는 임곗값이 없으면 해당 필드는 조용히 유든 절단값으로 대체되며, 대체 판정이 거짓 같은 값(falsy) 검사를 쓰므로 정당한 절단값 0도 대체된다. 값을 믿기 전에 maxYouden과 비교해야 한다. 또 단계 범위는 부동소수점 덧셈을 되풀이해 만들기 때문에 0.30000000000000004 같은 임곗값이 생기고, {min: 0, max: 0.3, step: 0.1}은 마지막 합이 0.3을 넘어 0.2에서 멈추며, 단계가 0 이하이면 끝나지 않는다. 정수 첨자로 목록을 만들고(min + i*step) 반올림하면 두 문제를 모두 피할 수 있다.

질환 상태의 함정

BiomarkerMeasurement.diseaseStatus의 형식은 boolean | DiseaseStatus이므로 'diseased', 'healthy', 'at_risk', 'unknown'을 담은 레코드도 형식상 올바르다. 그러나 analyzeSensitivity는 참·거짓 판정으로 상태를 라벨로 바꾸고, 비어 있지 않은 문자열은 모두 참이다. 따라서 'healthy'로 표시된 레코드가 질환군으로 계산된다. 모든 레코드가 문자열이면 라벨이 전부 1이 되어 B002("Must have both diseased and healthy samples")로 실패하므로 적어도 눈에 띈다. 문자열과 불리언이 섞여 있으면 문자열로 표시된 대조군이 조용히 환자군이 되어 아무 오류 없이 틀린 결과가 나온다. 상태를 먼저 엄격한 불리언으로 변환하고, 진단 정확도 분석에서는 at_risk와 unknown을 아예 제외해야 한다.

결과 예측

predictOutcome은 저장된 PredictionModel을 대상자 한 명의 표지자 값에 적용한다. logistic_regression이면 parameters.intercept와 model.biomarkers에 나열된 표지자별 계수로 선형 예측자 β₀ + Σβᵢxᵢ를 만들고 p = 1/(1 + e^−(β₀+Σβᵢxᵢ))를 돌려준다. 그 밖의 모형 유형(random_forest, neural_network, svm, other)은 계수만 저장되어 있어 모형을 실행할 수 없으므로, 있는 값마다 계수를 곱한 값의 평균을 [0, 1]로 자른 값으로 대신하는데 이는 어떤 의미로도 확률이 아니다. 이런 모형은 자체 실행 환경에서 점수를 내고 결과만 기록해야 한다.

분류 규칙은 고정되어 있다. p ≥ 0.5이면 positive이고, 위험 구간은 0.3 미만 low, 0.7 미만 medium, 0.7 이상 high이다. contributingFactors는 표지자별 βᵢxᵢ를 크기순으로 나열하는데, 절편이 빠져 있으므로 기여도의 합은 선형 예측자와 같지 않다. clinicalVariables는 받기만 하고 무시하며, confidenceInterval은 채워지지 않는다.

import { predictOutcome, PredictionModel } from '@wia/bio-004';

// 예시용 2-표지자 모형. 계수는 설명을 위해 임의로 정한 값이다.
const model: PredictionModel = {
  id: 'MODEL-DEMO-1', name: 'Two-marker demo', type: 'logistic_regression',
  biomarkers: ['markerX', 'markerY'],
  parameters: { intercept: -4.0, markerX: 0.08, markerY: 0.5 },
  performance: { trainingAUC: 0, validationAUC: 0 },
};

predictOutcome({ model, biomarkerValues: { markerX: 40, markerY: 1.2 } });
// 선형 예측자 -4.0 + 0.08*40 + 0.5*1.2 = -0.2
// => { prediction: 'negative', probability: 0.450, riskCategory: 'medium',
//      contributingFactors: [ { factor: 'markerX', contribution: 3.2 },
//                             { factor: 'markerY', contribution: 0.6 } ] }

predictOutcome({ model, biomarkerValues: { markerX: 40 } });
// markerY 가 없으면 조용히 0 으로 처리된다 => probability 0.310, riskCategory 'medium'

두 번째 호출이 가장 위험한 동작을 보여 준다. 빠진 표지자는 경고 없이 0으로 바뀌고 확률이 달라진다. 호출 전에 model.biomarkers의 모든 표지자가 있는지 확인해야 한다. 0.5 기준과 위험 구간은 SDK의 기본값일 뿐 임상적으로 검증된 판정 한계가 아니다. 실제 배치하는 모형은 자체 검증에서 확립한 기준값을 써야 한다(제6장).

4.5 generateReport와 상수

보고서 객체

generateReport(biomarker, validation, clinicalData?)는 클래스에서만 쓸 수 있다. 반환하는 BiomarkerReport에는 REPORT- 뒤에 현재 시각(밀리초)을 붙인 id, 'validation'으로 고정된 type, 전달된 Biomarker, 혼동행렬로 다시 계산한 합계를 담은 population 블록(환자군 = TP + FN, 대조군 = TN + FP), 검증 결과의 diagnosticPerformance와 rocAnalysis, 선택적 clinicalCorrelations와 prognosticAnalysis, recommendations 목록, 그리고 dateGenerated와 version을 담은 metadata가 들어 있다.

권고 문구는 단순한 규칙을 따른다. 민감도가 80 % 미만이면 "Consider lowering threshold", 특이도가 80 % 미만이면 "Consider raising threshold"가 추가된다. 이어서 등급별로 한 문장이 붙는다. excellent는 임상 적용 준비 완료, good은 다기관 검증 후보, fair는 다른 표지자와 결합할 후보, poor와 insufficient는 임상 사용 비권장으로 기술된다. "ready for clinical implementation"이라는 문구는 AUC 하나로 생성되며 분석적 검증(제5장), 외부 코호트, 임상적 유용성(제6장)을 고려하지 않는다. 그대로 전달하지 말고 편집해야 한다. 시각 기반 id는 동시 실행 시 고유하지 않으므로 바꿔야 하며, dateGenerated는 직렬화하면 제3장이 요구하는 ISO 8601 UTC가 된다.

상수

BIOMARKER_CONSTANTS는 SDK의 수치 정책을 모은 고정(as const) 객체이다. 코드가 실제로 쓰는 항목은 일부뿐이고 나머지는 의도를 기록한 것이다. 표 4-2에 전부 정리하였다.

표 4-2. 1.0판의 BIOMARKER_CONSTANTS
상수값코드에서 사용?비고
MIN_VALIDATION_SAMPLES100예, calculateROC와 validateBiomarker집단별이 아니라 전체 건수
MIN_AUC_CLINICAL0.70예, validateBiomarker의 경고fair 경계와 같음
CLINICAL_UTILITY_THRESHOLDS0.90 / 0.80 / 0.70 / 0.60예, 등급 판정명세의 AUC 구간과 하단이 다름(제2장)
TARGET_SENSITIVITY_SCREENING0.90아니요결과는 백분율인데 이 값은 비율
TARGET_SPECIFICITY_CONFIRMATORY0.95아니요비율
MAX_CV_DIAGNOSTIC15.0아니요백분율. 다른 문서는 다른 한계를 제시(제5장)
MAX_CV_SCREENING20.0아니요백분율
MIN_STATISTICAL_POWER0.80아니요1.0판에는 검정력 계산이 없음
DEFAULT_CONFIDENCE_LEVEL0.95아니요calculateROC에 기본값이 따로 하드코딩됨
ALPHA0.05아니요1.0판에는 가설 검정이 없음

민감도·특이도 경고의 80 % 기준은 이 객체가 아니라 코드에 하드코딩되어 있다. 선별 용도와 확진 용도의 목표가 다른 구현은 반환된 성능 값을 직접 검사해야 한다.

4.6 오류

오류는 Error의 하위 클래스인 BiomarkerError의 인스턴스이며, name은 'BiomarkerError'이고, BiomarkerErrorCode 열거형의 code, 사람이 읽는 message, 선택적 details 객체를 가진다. 열거형은 코드 열 개를 정의하고, 명세 §8.3은 그중 앞의 여섯 개를 같은 번호, 호환되는 의미로 정의한다. 1.0판이 실제로 던지는 코드는 두 개뿐이다.

표 4-3. 명세와 SDK의 오류 코드
코드SDK 이름명세 §8.3 의미 / 조치1.0판에서 발생 위치
B001INSUFFICIENT_SAMPLES표본 수 부족 / 표본 추가 수집calculateROC, 항목 100개 미만
B002INVALID_MEASUREMENTS잘못된 측정값 / 데이터 품질 확인calculateROC, 길이 불일치 또는 한 집단만 있음
B003MISSING_REFERENCE참조 표준 없음 / 기준 검사 정의발생하지 않음
B004POOR_PERFORMANCE분석 성능 불량 / 분석법 개선발생하지 않음
B005FAILED_VALIDATION검증 실패 / 바이오마커 재검증발생하지 않음
B006LOW_STATISTICAL_POWER통계적 검정력 부족 / 표본 수 확대발생하지 않음
B007INVALID_THRESHOLD명세에 없음발생하지 않음
B008DATA_QUALITY_ISSUE명세에 없음발생하지 않음
B009MISSING_CLINICAL_DATA명세에 없음발생하지 않음
B010CALCULATION_ERROR명세에 없음발생하지 않음

쓰이지 않는 코드도 쓸모가 있다. SDK를 감싸는 애플리케이션이 자체 검사에서 이 코드를 발생시키면 된다. 검증 연구에 referenceStandard가 없으면 B003, 절단값이 측정 범위를 벗어나면 B007, 제외되지 않은 용혈·지방혈증·황달 검체가 있으면 B008, 결과 데이터가 빠졌으면 B009, 계산이 유한하지 않은 수를 내면(어떤 임곗값에서 한 집단이 비면 민감도가 0/0이 된다) B010을 쓴다. B007–B010은 SDK의 확장이므로, 명세와 엄격하게 상호 운용해야 하는 시스템은 상대에게서 B001–B006만 온다고 예상해야 한다.

types.ts에는 { success: true, data } 또는 { success: false, error }인 Result<T, E> 합 형식과 비동기 변형도 정의되어 있지만, 이를 반환하는 SDK 함수는 없고 모두 예외를 던진다. 파이프라인이 예외보다 값을 선호한다면 얇은 감싸개로 라이브러리를 바꾸지 않고도 의도한 방식을 쓸 수 있다.

import { calculateROC, BiomarkerError, ROCRequest, ROCResult, Result } from '@wia/bio-004';

export function safeROC(req: ROCRequest): Result<ROCResult, BiomarkerError> {
  try {
    return { success: true, data: calculateROC(req) };
  } catch (e) {
    if (e instanceof BiomarkerError) return { success: false, error: e };
    throw e; // 프로그래밍 오류는 변환하지 않는다
  }
}

const r = safeROC({ trueLabels: [1, 0, 1], predictions: [0.9, 0.2, 0.7] });
if (!r.success) console.log(r.error.code); // 'B001' (표본 100건 미만)

4.7 명령행 도구

wia-bio-004는 Bash 스크립트로, install.sh가 /usr/local/bin에 설치하며(필요하면 sudo 사용) 산술에는 bc를 쓴다. 명령은 여섯 가지이며 표 4-4에 요약하였다.

표 4-4. CLI 개요와 1.0판의 실제 동작
명령옵션명시된 목적1.0판 동작
calc-roc--data <csv>, --threshold <t>(기본값 0.5)ROC 곡선과 AUC파일 존재를 확인하고 줄 수를 센다. 고정값 TP 75 / TN 85 / FP 15 / FN 25와 AUC 0.85를 출력한다. 임곗값은 화면에 되풀이할 뿐이다.
validate--type <type>, --data <json>성능 검증파일을 열지 않는다. 고정된 건수, 지표, "PASS" 검사를 출력한다.
analyze--data <csv>, --sensitivity, --specificity민감도·유병률 분석파일을 열지 않는다. 고정된 표를 출력한다.
report--input <file>, --output <file>(기본값 report.txt)검증 보고서고정된 텍스트 서식을 출력 경로에 쓰며, 기존 파일을 덮어쓴다.
predict--model <json>, --data <csv>결과 예측두 파일 모두 열지 않는다. 고정된 확률과 위험 구간을 출력한다.
version, help없음(help는 --help, -h도 허용)정보설명대로 동작한다. --version 플래그는 없으며 알 수 없는 명령으로 거부되고 종료 상태 1을 낸다.

1.0판 CLI는 의도한 명령 모양을 보여 주는 시연물로 읽어야 한다. calc-roc의 데이터 파일이 있는지 확인하고 줄 수를 세는 것을 빼면 어떤 데이터도 읽지 않는다. 출력되는 지표는 스크립트에 고정되어 있고 입력이 무엇이든 같다.

# 일부 발췌, 색상 코드 제거
$ printf 'label,value\n1,0.9\n' > one-row.csv
$ wia-bio-004 calc-roc --data one-row.csv --threshold 0.7
  Total samples: 1
✓ AUC: 0.85
  Sensitivity: 75.00%
  PPV: 83.00%
  NPV: 77.00%
✓ Clinical Utility: GOOD (AUC ≥ 0.80)

한 줄짜리 파일이 AUC 0.85와 "GOOD" 등급을 받는다. 출력되는 PPV와 NPV도 반올림이 아니라 버림 값인데, bc가 100을 곱하기 전에 소수 둘째 자리에서 나눗셈을 끊기 때문이다(75/90이 83.3 %가 아니라 83.00 %로 표시된다). analyze 명령의 유병률 표에는 같은 표에 적힌 민감도·특이도와 맞지 않는 NPV가 들어 있고(올바른 값은 제2장 참조), report 서식은 입력과 상관없이 "ADEQUATE (≥100 cases and controls)"와 "Statistical Power: SUFFICIENT"를 적는다. 이 숫자들은 어느 것도 결과로 인용하거나, 검증 파일에 붙여 넣거나, 시험 기준값으로 써서는 안 된다. README가 제시한 --output report.pdf는 이름만 .pdf인 일반 텍스트 파일을 만들고, README의 wia-bio-004 --version 확인은 실패한다. wia-bio-004 version을 써야 한다.

실제 명령행 작업에는 CSV나 제3장의 JSON을 읽어 SDK를 호출하고, CLI의 명령 이름을 그대로 따르며, SDK가 예외를 던지면 BiomarkerError 코드와 함께 0이 아닌 상태로 끝나는 짧은 Node.js 스크립트를 쓴다. 제8장은 "CLI가 데이터를 읽도록 하는 것"을 미해결 과제로 꼽는다.

4.8 REST 레지스트리 인터페이스

엔드포인트

두 번째 단계 문서는 바이오마커 레지스트리를 위한 HTTP 인터페이스를 명세한다. 기본 경로는 /biomarker/v1로 끝나고 자원 경로에는 /v1 접두어가 붙는다. 이것은 명세된 인터페이스일 뿐이다. 저장소에는 이를 구현한 서버가 없으며, 문서의 예시 호스트 이름을 운영 중인 서비스로 가정해서는 안 된다. 문서는 네 가지 작업을 제시한다.

표 4-5. 2단계 문서의 레지스트리 작업
작업목적주요 페이로드 필드데이터 형식과의 관계
POST /v1/biomarkers바이오마커 정의 등록name, type, disease, analytes[], algorithm, cutoff, performance바이오마커 정의(3.4절)의 축약형. type은 모달리티가 아니라 임상적 역할
GET /v1/biomarkers?disease=&type=레지스트리 검색id, name, type, disease, therapy, validationStatus를 가진 biomarkers[] 반환임상적 역할(예: predictive)로 조회
POST /v1/clinical/results환자 결과 제출patientId, biomarkerId, specimenId, result{value, unit, interpretation}, performedBy, performedDate측정 레코드(3.1절)의 부분집합
POST /v1/validation/studies검증 연구 등록biomarkerId, studyType, design, sampleSize, endpoints[], expectedCompletion계획된 연구의 등록. 결과는 3.5절 레코드를 쓴다

예시 식별자와 값으로 결과를 제출하면 다음과 같다.

POST /v1/clinical/results
Content-Type: application/json

{
  "patientId":   "PT-000123-CODED",
  "biomarkerId": "BM_EXAMPLE_01",
  "specimenId":  "SPEC-2026-000045",
  "result": { "value": 42.5, "unit": "ng/mL", "interpretation": "above cutoff" },
  "performedBy":   "LAB-EXAMPLE-01",
  "performedDate": "2026-03-02"
}

구현 전에 정리해야 할 불일치

이 인터페이스를 구현하려면 문서가 열어 둔 몇 가지를 결정해야 한다. 첫째, 이름 표기이다. REST 페이로드는 patientId, biomarkerId를 쓰고 측정 레코드와 SDK는 biomarkerID, patientID를 쓴다. 서버는 전송 계층에서 REST 이름을 받고 명시적으로 대응시켜야 하며 추측해서는 안 된다. 둘째, 레지스트리 항목의 type은 임상적 역할(diagnostic, predictive)이지만 SDK의 Biomarker.type은 모달리티(protein, genetic 등)이다. 두 축이 모두 필요한 이유는 제1장이 설명했으며, 레지스트리는 둘을 별도 필드에 저장해야 한다. 셋째, 레지스트리는 성능을 비율(0.91)로 표현하고 SDK의 DiagnosticPerformance는 백분율을 쓴다. 저장 규약을 하나로 정하고 경계에서 변환해야 한다. 넷째, 예시의 studyType: "clinical_validation"은 서로 호환되지 않는 연구 유형 열거 세 가지 가운데 하나이다(제3장 3.5절). 이 책은 SDK 열거(discovery, validation, clinical_utility, multi_center)를 권고한다. 마지막으로 제출 페이로드의 result에는 value, unit, 자유 텍스트 interpretation만 있고 3.1절의 검체 유형, 측정 방법, 채취·측정 시각, 품질 플래그가 빠져 있다. 나중의 검증 분석을 위해 결과를 받는 서버라면 이 항목들을 필수로 요구해야 한다.

문서의 페이로드 예시에는 특정 표지자의 성능 수치도 들어 있는데, 이는 검증되지 않은 예시이다. 레지스트리에 예시 숫자를 초기값으로 넣어서는 절대 안 되며, 저장되는 모든 performance 블록은 그 값을 만든 검증 연구를 가리켜야 한다.

Python 클라이언트 초안

같은 문서는 Python 클라이언트 wia_bio.BiomarkerClient(api_key=…)의 초안도 제시한다. 메서드는 세 묶음으로, client.biomarkers.create(name, type, disease, analytes), client.validation.analytical(biomarker_id, LOD_samples, precision_replicates), client.clinical.submit_result(patient_id, biomarker_id, value, interpretation)이다. 저장소에는 이런 패키지가 없다. 이 초안은 의도한 클라이언트 모양으로서만 쓸모가 있으며, 분석적 검증 호출의 매개변수는 제5장의 LOD·정밀도 프로토콜에 대응한다.

서버를 책임 있게 구현하기

레지스트리를 만드는 팀은 위의 대응 결정을 명시한 자체 구현의 OpenAPI 기술서를 작성해야 한다. 임상 결과는 개인 건강 정보이므로 인증은 단일 API 키가 아니라 제7장의 접근 통제 모델을 따라야 한다. 결과 제출은 클라이언트가 준 식별자(예: 검체 식별자와 분석물의 조합)를 기준으로 멱등이어야 하고, 오류 본문은 표 4-3의 B 코드를 재사용해야 하며, 검증 엔드포인트는 미리 계산된 지표를 받지 말고 저장된 측정값으로 SDK를 직접 실행해야 한다.

요약

WIA-BIO-004의 1.0판 소프트웨어는 한계를 알고 쓰면 쓸 만하다. TypeScript SDK는 내보내기 목록만 고치면 실제 결과를 계산하고, 명령행 도구는 고정된 시연 숫자를 출력하며, REST 레지스트리와 Python 클라이언트는 참조 구현이 없는 명세된 인터페이스이다. 표 4-6은 이 장에서 나온 작업 규칙을 모은 것이다.

표 4-6. 1.0판 도구 사용 규칙
상황규칙
AUC 보고자동 격자를 쓰고 정확한 Mann–Whitney AUC로 교차 확인한다. 지정 임곗값으로 얻은 AUC는 보고하지 않는다
신뢰구간0.95나 0.99만 요청한다. DeLong이 아니라 Hanley–McNeil이라고 적는다
예측도사용 목적 유병률을 항상 넘기고 PPV/NPV 옆에 적는다
입력 준비라벨은 0/1, 상태는 엄격한 불리언, 단위는 하나, 높은 값이 질환을 뜻하도록 맞춘다. SDK 전에 사전 검사를 돌린다
절단값 선택사용 목적에 따라 sensitivity90, specificity90, 유든 중에서 고르고 규칙을 기록한다
예측모형 입력이 모두 있는지 확인하고, 0.5가 아니라 모형의 검증된 기준값을 쓴다
보고서시각 기반 ID를 바꾸고, 생성된 권고 문구를 공개 전에 검토한다
CLI출력은 시연용으로만 본다. 실제 데이터에는 SDK 기반 스크립트를 쓴다
REST 레지스트리OpenAPI 기술서를 작성하고, 이름과 단위를 명시적으로 대응시키고, 제출을 멱등으로 만든다

제5장은 소프트웨어에서 검사실로 넘어가, 이 도구들이 계산하는 모든 숫자의 뒤를 받쳐야 하는 분석적 검증 프로토콜과 정도관리를 다룬다.