Kruskal-Wallis Test를 백엔드에서 사용해보자

오늘은 Kruskal-Wallis Test 를 웹에서 수행하기 위해서 어떤 식으로 스크립트를 작성하고 구성하면 될지 알아보겠습니다.

Kruskal-Wallis Test 에 대한 전반적인 설명을 통해 이해하고, 코드를 작성하겠습니다. 

Kruskal-Wallis Test

				
					CPC  혈압  순위
CPC1 115  1
CPC1 120  2
CPC1 125  3
CPC2 130  4.5
CPC2 130  4.5
CPC3 135  6.5
CPC3 135  6.5
CPC3 140  8.5
CPC4 140  8.5
CPC4 145  10.5
CPC5 145  10.5
CPC5 150  12.5
CPC5 150  12.5
CPC4 155  14.5
CPC4 155  14.5
CPC5 160  16.5
CPC5 160  16.5
CPC3 165  18.5
CPC2 165  18.5
CPC1 170  20
CPC2 170  20

				
			

분석은 위와 같은 데이터를 통해 이뤄집니다.

순위 부분을 제외하고 처음에는 CPC, 혈압이라는 열만 있으면 됩니다.

여기서 중요한 점이 있습니다.

반드시 하나의 열은 3개 이상의 값을 가진 “범주형” 속성을 가지고 있어야 합니다.

또한 다른 하나의 열은 “연속형” 속성을 가지고 있어야 합니다.

Kruskal-Wallis 테스트는 여러 독립된 그룹의 데이터들이 정규분포를 따르지 않을 때 각 그룹의 차이를 확인하기 위한 비모수 검정입니다.

따라서 각 그룹의 평균을 이용하는 분석이 아닌, 순위를 매겨 분석합니다.

위 데이터에 있는 “순위” 라는 열이 바로 이 분석을 위해서 매겨진 가상의 열입니다.

이 가상의 순위는 “연속형” 속성의 데이터 중에서 절대값이 가장 작은 값부터 1이 매겨집니다.

만약 3번째 순위에 있어야 할 값이 2개다? 그럼 3.5 와 같이 매겨집니다.

위에서 그룹으로 사용된 CPC의 종류는 총 5개이지요? 

각 그룹의 합을 아래와 같이 구합시다.

CPC1의 순위 합: 2 + 3 + 20 = 25

CPC2의 순위 합: 4.5 + 4.5 + 18.5 + 20 = 47.5

CPC3의 순위 합: 6.5 + 6.5 + 8.5 + 18.5 = 40

CPC4의 순위 합: 8.5 + 10.5 + 14.5 + 14.5 = 48

CPC5의 순위 합: 10.5 + 12.5 + 16.5 + 16.5 = 56

이제 이 정보들을 이용해서 각종 통계량을 구해봅시다.

여기서 N은 총 샘플의 수가 됩니다.

K는 그룹의 전체 수입니다.

T(i) 는 i번째 그룹의 순위 합입니다. 예컨대 i가 1이라면, CPC1 그룹의 순위 합이라는 거죠.

n(i) 는 i번째 그룹의 샘플 수입니다. 

위와 같이 계산한 값이 바로 통계량 H입니다.

H 통계량은 그룹 간의 차이가 얼마나 있는지를 평가하는 지표가 됩니다. 

이 값이 클 수록, 그룹 간의 차이가 클 가능성이 높습니다. 이 값은 카이제곱 분포를 따르며, 주어진 자유도에서 P-값을 계산하는 데에 사용됩니다.

다음은 자유도입니다.

자유도는 그룹의 전체 수에서 1을 뺀 값입니다.

만약 그룹이 총 5개라면? 4가 되겠죠.

결과적으로 나타나는 P값이 일반적으로 0.05보다 작다면 그룹 간의 차이가 통계적으로 유의미하다고 결론지을 수 있습니다.

이어서 분석을 위한 스크립트 설계를 하겠습니다.

R 스크립트

				
					# 필요한 패키지 불러오기
library(ggplot2)
library(gridExtra)
library(svglite)
library(jsonlite)

# 커맨드라인 인자로부터 파일 이름, 종속 변수 이름들을 가져오기
args <- commandArgs(trailingOnly = TRUE)

# 인자 파싱
args_list <- list()
for (arg in args) {
  split_arg <- strsplit(arg, "=")[[1]]
  if (length(split_arg) == 2) {
    args_list[[split_arg[1]]] <- split_arg[2]
  }
}

# 인자 설정
filename <- args_list[["filename"]]
Group <- args_list[["Group"]]
Values <- args_list[["Values"]] #"A,B,C,D" 와 같은 문자열을 받아옴 
goalname <- args_list[["goalname"]]
graphicgoal <- args_list[["graphicgoal"]]

# 데이터 읽기
data <- read.csv(filename)

# 데이터 구조 확인 (디버깅용)
print(str(data))
print(head(data))
print(colnames(data))  # 데이터 프레임의 열 이름을 출력하여 Group 변수가 존재하는지 확인

# Group 열과 Value 열이 데이터 프레임에 존재하는지 확인
if (!(Group %in% colnames(data))) {
  stop(paste("Group column not found in the data:", Group))
}

# 문자열을 리스트로 변경
ValueList <- unlist(strsplit(Values, split = ","))

# Value 열들이 데이터 프레임에 존재하는지 확인
for (value in ValueList) {
  if (!(value %in% colnames(data))) {
    stop(paste("Value column not found in the data:", value))
  }
}

# 범주형 열을 검토하고, 범주형이 아니라고 인식 시, 범주형으로 변경
if (!is.factor(data[[Group]])) {
  data[[Group]] <- as.factor(data[[Group]])
}

# 범주형 열이 결측치가 아닌 경우의 행만을 데이터에 할당
data <- data[!is.na(data[[Group]]), ]

# Kruskal-Wallis Test 결과를 저장할 리스트 초기화
results <- list()

# 이제부터 ValueList 에 할당된 열들을 순회하며 작업.
# 그룹에 따라 분석이 필요한 값들을 차근차근 분석.
for (i in seq_along(ValueList)) {
  value <- ValueList[i]
  
  # 분석이 필요한 해당 열의 결측치를 제거 후, 다시 데이터에 할당.
  data_sub <- data[!is.na(data[[value]]), ]
  
  # 연속형 변수인지 확인
  if (!is.numeric(data_sub[[value]])) {
    next  # 연속형 변수가 아닌 경우 다음 변수로 건너뜁니다.
  }
  
  # 필터링 결과가 비어 있는지 확인
  if (nrow(data_sub) == 0) {
    next  # 비어 있는 경우 다음 변수로 건너뜁니다.
  }
  
  # Kruskal-Wallis Test 수행
  formula <- as.formula(paste(value, "~", Group))
  test_result <- kruskal.test(formula, data = data_sub)
  
  # 결과 저장 (htest 객체를 수동으로 변환)
  results[[value]] <- list(
    statistic = test_result$statistic,
    p_value = test_result$p.value,
    parameter = test_result$parameter,
    method = test_result$method,
    data_name = test_result$data.name
  )
  
  # 그래프 생성
  p <- ggplot(data_sub, aes_string(x = Group, y = value)) +
    geom_boxplot() +
    ggtitle(paste("Boxplot of", value, "by", Group)) +
    xlab(Group) +
    ylab(value)
  
  # SVG 파일로 저장
  svg_filename <- paste0(sub(".svg", "", graphicgoal), "_", i, ".svg")
  svglite::svglite(file = svg_filename, width = 8, height = 6)
  print(p)
  dev.off()
}

# 결과를 JSON 파일로 저장
result_json <- toJSON(results, pretty = TRUE)
write(result_json, file = goalname)

# JSON 데이터 출력 (디버깅용)
print(result_json)

				
			

코드는 다음과 같이 나타내었습니다.

이 코드의 목적은 다음과 같습니다.

1. 분석을 위한 DB를 불러온다.

2. DB에는 “범주형 변수” 1개와 이 범주형 변수에 입력된 그룹을 이용하여 분석하고 싶은 “연속형 변수” 여러 개가 있다. 

3. “연속형 변수” 의 개수에 따라서 각각의 Box Plot Graph 및 JSON 데이터를 생성한다. 

가 되겠습니다.

앞에서부터 코드를 뜯어보겠습니다. 

				
					# 필요한 패키지 불러오기
library(ggplot2)
library(gridExtra)
library(svglite)
library(jsonlite)

# 커맨드라인 인자로부터 파일 이름, 종속 변수 이름들을 가져오기
args <- commandArgs(trailingOnly = TRUE)

# 인자 파싱
args_list <- list()
for (arg in args) {
  split_arg <- strsplit(arg, "=")[[1]]
  if (length(split_arg) == 2) {
    args_list[[split_arg[1]]] <- split_arg[2]
  }
}

# 인자 설정
filename <- args_list[["filename"]]
Group <- args_list[["Group"]]
Values <- args_list[["Values"]] #"A,B,C,D" 와 같은 문자열을 받아옴 
goalname <- args_list[["goalname"]]
graphicgoal <- args_list[["graphicgoal"]]

# 데이터 읽기
data <- read.csv(filename)
				
			

필요한 패키지를 읽어옵니다.

코드를 실행할 때 함께 건네는 인자들을 이용하여 R 스크립트 내에서 사용할 형태로 인자를 다시 할당합니다.

이때 각 인자에는 적합한 형식이 있습니다.

filename 은 문자열, Group 도 문자열, Values 는 문자열이지만 “,” 으로 구분되어 값들이 저장되어 있으면 됩니다. 

goalname 은 json 데이터가 저장될 경로, graphicgoal 은 생성한 plot 의 svg 파일이 저장될 경로입니다.

이후 전달 받은 데이터의 경로 filename 을 이용하여 데이터를 읽습니다.

				
					# 데이터 구조 확인 (디버깅용)
print(str(data))
print(head(data))
print(colnames(data))  # 데이터 프레임의 열 이름을 출력하여 Group 변수가 존재하는지 확인

# Group 열과 Value 열이 데이터 프레임에 존재하는지 확인
if (!(Group %in% colnames(data))) {
  stop(paste("Group column not found in the data:", Group))
}

# 문자열을 리스트로 변경
ValueList <- unlist(strsplit(Values, split = ","))

# Value 열들이 데이터 프레임에 존재하는지 확인
for (value in ValueList) {
  if (!(value %in% colnames(data))) {
    stop(paste("Value column not found in the data:", value))
  }
}

# 범주형 열을 검토하고, 범주형이 아니라고 인식 시, 범주형으로 변경
if (!is.factor(data[[Group]])) {
  data[[Group]] <- as.factor(data[[Group]])
}

# 범주형 열이 결측치가 아닌 경우의 행만을 데이터에 할당
data <- data[!is.na(data[[Group]]), ]

				
			

디버깅 코드는 넣어도 되고 넣지 않아도 됩니다.

단순히 전달 받는 데이터의 형식을 보고 싶어 넣었던 겁니다.

중요한건 11번째 줄부터 있는 문자열을 리스트로 바꾸는 방법입니다.

unlist(stripsplit(“문자열”,”,”)) 방법을 이용하면 “문자열” 에 담긴 “,” 을 기준으로 인자들을 나누어 리스트로 만들 수 있습니다.

이후 이 리스트를 순회하며 연속형 변수들의 데이터를 이용할 수 있게 됩니다.

21번째 줄을 보면, Group 으로 받은 범주형 변수의 열 이름을 이용하여 만약 현재 DB의 해당 열이 범주형이 아니라면, 범주형으로 지정해주고 있습니다.

또한 data[조건, ] 을 이용하면 data 에서 조건에 해당하는 행들을 남길 수 있는데, 이 조건에 해당하는 행만 남긴 데이터프레임을 다시 data 에 할당합니다. 

				
					# Kruskal-Wallis Test 결과를 저장할 리스트 초기화
results <- list()

# 이제부터 ValueList 에 할당된 열들을 순회하며 작업.
# 그룹에 따라 분석이 필요한 값들을 차근차근 분석.
for (i in seq_along(ValueList)) {
  value <- ValueList[i]
  
  # 분석이 필요한 해당 열의 결측치를 제거 후, 다시 데이터에 할당.
  data_sub <- data[!is.na(data[[value]]), ]
  
  # 연속형 변수인지 확인
  if (!is.numeric(data_sub[[value]])) {
    next  # 연속형 변수가 아닌 경우 다음 변수로 건너뜁니다.
  }
  
  # 필터링 결과가 비어 있는지 확인
  if (nrow(data_sub) == 0) {
    next  # 비어 있는 경우 다음 변수로 건너뜁니다.
  }
  
  # Kruskal-Wallis Test 수행
  formula <- as.formula(paste(value, "~", Group))
  test_result <- kruskal.test(formula, data = data_sub)
  
  # 결과 저장 (htest 객체를 수동으로 변환)
  results[[value]] <- list(
    statistic = test_result$statistic,
    p_value = test_result$p.value,
    parameter = test_result$parameter,
    method = test_result$method,
    data_name = test_result$data.name
  )
  
  # 그래프 생성
  p <- ggplot(data_sub, aes_string(x = Group, y = value)) +
    geom_boxplot() +
    ggtitle(paste("Boxplot of", value, "by", Group)) +
    xlab(Group) +
    ylab(value)
  
  # SVG 파일로 저장
  svg_filename <- paste0(sub(".svg", "", graphicgoal), "_", i, ".svg")
  svglite::svglite(file = svg_filename, width = 8, height = 6)
  print(p)
  dev.off()
}


				
			

여기가 핵심입니다.

우선은 results 라는 빈 리스트를 하나 생성하는 것으로 시작합니다.

seq_along 방식으로 시퀀스를 만들어 줍시다. 이렇게 하면, 무조건 1부터 시작하게 되어 인수로 어떤 리스트를 사용할지만 넣어줘도 됩니다.

첫 번째 i는 1로 바로 시작한다는 뜻이죠.

i가 1로 시작했다는 관점에서 볼까요?

 

				
					  value <- ValueList[i]
  
  # 분석이 필요한 해당 열의 결측치를 제거 후, 다시 데이터에 할당.
  data_sub <- data[!is.na(data[[value]]), ]
				
			

value 에는 우리가 받았던 여러 개의 연속형 변수 열중에서 첫 번째 열의 값이 할당됩니다.

앞서 변주형 데이터의 결측치를 제거했던 것과 마찬가지로 이번에는 해당하는 연속형 변수 데이터의 결측치를 제거합니다.

이렇게 되면 우리가 분석을 하고 싶은 “범주형 변수” 와 “1번째 연속형 변수” 2쌍의 결측치가 제거되는 거죠.

이 깨끗한 데이터를 루프 문 안에서는 data_sub 로 받습니다.

왜내하면 i가 2로 넘어갔을 때는 다시 “범주형 변수”의 결측치만 제거된 data 를 이용해서 “2번째 연속형 변수” 의 결측치를 제거해야 하니까요.

 

				
					  # 연속형 변수인지 확인
  if (!is.numeric(data_sub[[value]])) {
    next  # 연속형 변수가 아닌 경우 다음 변수로 건너뜁니다.
  }
  
  # 필터링 결과가 비어 있는지 확인
  if (nrow(data_sub) == 0) {
    next  # 비어 있는 경우 다음 변수로 건너뜁니다.
  }
				
			

혹시나 해당 연속형 데이터가 연속형 데이터가 아닌 경우, next 로 곧장 2번째 i로 넘어갑니다.

만약 해당 연속형 데이터가 몽땅 결측치인 경우가 있겠죠, 이 때도 next 로 곧장 2번째 i로 넘어갑니다.

 

				
					  # Kruskal-Wallis Test 수행
  formula <- as.formula(paste(value, "~", Group))
  test_result <- kruskal.test(formula, data = data_sub)
  
  # 결과 저장 (htest 객체를 수동으로 변환)
  results[[value]] <- list(
    statistic = test_result$statistic,
    p_value = test_result$p.value,
    parameter = test_result$parameter,
    method = test_result$method,
    data_name = test_result$data.name
  )
  
				
			

as.formula 는 객체를 생성하는 방법입니다.

lm, glm, krusakal.test 와 같은 경우 인자로 객체를 받아야 하기 때문에 사용합니다.

formula 에는 우리가 분석에 사용할 연속변수와 범주형변수를 묶어서 넣어줍시다.

이후 이 formula 를 Kruskal.test 의 첫 번째 인자로 넣어주고 두 번째 인자에서 사용하기 위한 데이터로 앞서 결측치를 완전히 제거한 data_sub 로 지정합니다.

이 결과를 test_result 에 넣어주고, 필요한 인자들을 뽑아 가장 앞에서 생성한 빈 리스트인 results 에 value 라는 key값에 해당하여 들어갈 수 있도록 조정합니다.

이걸 추후 json 데이터로 변경하자면 이렇게 됩니다. 

				
					{
"Value 1" : [statistic, p_vale, prarameter, method, data_name],
"Value 2" : statistic, p_vale, prarameter, method, data_name],
...
}
				
			

위와 같이 각 연속변수에 대해서 실행한 결과값을 저장하면 프론트엔드로 보내 유용하게 사용할 수 있을 것입니다.

 

				
					  # 그래프 생성
  p <- ggplot(data_sub, aes_string(x = Group, y = value)) +
    geom_boxplot() +
    ggtitle(paste("Boxplot of", value, "by", Group)) +
    xlab(Group) +
    ylab(value)
  
  # SVG 파일로 저장
  svg_filename <- paste0(sub(".svg", "", graphicgoal), "_", i, ".svg")
  svglite::svglite(file = svg_filename, width = 8, height = 6)
  print(p)
  dev.off()
}

# 결과를 JSON 파일로 저장
result_json <- toJSON(results, pretty = TRUE)
write(result_json, file = goalname)

# JSON 데이터 출력 (디버깅용)
print(result_json)
				
			

시각화 부분에 대해서는 굳이 설명을 길게 붙이지 않겠습니다.

루프문 내에서 SVG 파일 특정 경로에 저장을 해주고, 루프문 바깥에서 저장된 results 를 이용하여 전체 json 데이터를 저장하며 코드는 종료됩니다. 

백엔드 실행 함수

				
					function runKruskalWallis(
  res,
  filename,
  groupVar,
  valueVar,
  goalname,
  graphicgoal
) {
  const scriptPath = "example.R";
  const valueList = valueVar.split(",").map((value) => value.trim());
  const rProcess = spawn("Rscript", [
    scriptPath,
    `filename=${filename}`,
    `Group=${groupVar}`,
    `Values=${valueVar}`,
    `goalname=${goalname}`,
    `graphicgoal=${graphicgoal}`,
  ]);

  rProcess.stdout.on("data", (data) => {
    console.log(data.toString());
  });

  rProcess.stderr.on("data", (data) => {
    console.error(`R Error: ${data}`);
  });

  rProcess.on("close", (code) => {
    console.log(`R process exited with code ${code}`);

    if (code !== 0) {
      console.error("R script execution failed with code:", code);
      return res.status(500).send("R script execution failed");
    }
    try {
      const jsonPath = goalname;
      const svgPaths = [];

      // valueList를 기반으로 SVG 파일 경로 생성 및 Base64 인코딩
      valueList.forEach((value, index) => {
        const svgPath = graphicgoal.replace(".svg", `_${index + 1}.svg`);
        if (fs.existsSync(svgPath)) {
          const svgData = fs.readFileSync(svgPath, "utf8");
          const base64data = Buffer.from(svgData).toString("base64");
          svgPaths.push(`data:image/svg+xml;base64,${base64data}`);
        }
      });

      // JSON 데이터 읽기
      const jsonData = fs.readFileSync(jsonPath, "utf8");

      // JSON 파싱
      const parsedJsonData = JSON.parse(jsonData);

      res.status(200).json({ plot: svgPaths, json: parsedJsonData });
    } catch (err) {
      console.error("Failed to process results:", err);
      if (!res.headersSent) {
        res.status(500).send("Failed to process results");
      }
    }
  });
}
				
			

R 스크립트 실행을 위해 EC2 백엔드에서 사용할 실행 함수입니다.

다음과 같이 하면 55번째 줄과 같은 형식으로 데이터를 프론트엔드에 전송 가능합니다.

마치며...

이제 비모수 검정에서 이진 변수가 아닌, 다양한 데이터를 가진 범주형 변수를 이용한 통계 실행을 사용 가능합니다.