Wilcoxon Sum-Rank Test 의 웹 구현을 위하여

Wilcoxon Sum Rank Test 통계를 웹에서 구현하기 위해서 어떤 식으로 스크립트를 작성하면 좋을지 알아보겠습니다.

차근차근 하죠.

우선 Wilcoxon Sum Rank Test 에 대해서 알아보겠습니다. 

Wilcoxon Sum-Rank Test

				
					| ID | Group  | Value |
|----|--------|-------|
| 1  | Group1 | 5     |
| 2  | Group1 | 6     |
| 3  | Group1 | 7     |
| 4  | Group1 | 8     |
| 5  | Group2 | 1     |
| 6  | Group2 | 2     |
| 7  | Group2 | 3     |
| 8  | Group2 | 4     |

				
			

예컨대 위와 같은 가상의 데이터가 있다고 가정합시다.

Sum Rank Test 를 위해서는 범주형 이진변수 열과, 그에 대응하는 연속형 변수 열이 필요합니다.

여기서 범주형 이진 변수 열이 Group, 연속형 변수 열이 Value 가 될 것입니다. 

 

				
					| ID | Group  | Value | Rank |
|----|--------|-------|------|
|  1 | Group1 |  5    |  5   |
|  2 | Group1 |  6    |  6   |
|  3 | Group1 |  7    |  7   |
|  4 | Group1 |  8    |  8   |
|  5 | Group2 |  1    |  1   |
|  6 | Group2 |  2    |  2   |
|  7 | Group2 |  3    |  3   |
|  8 | Group2 |  4    |  4   |


				
			

이제 여기 각 데이터에 Rank를 부여할 것입니다.

이 Rank는 연속형 변수의 값이 가장 작은 데이터에 1, 그 다음으로 작은 데이터에 2가 부여되는 방식입니다.

이후, 이 Rank 값을 통해서 분석을 수행합니다.

Group은 이진 변수였죠? 즉, 2개의 값만 존재하는 데이터입니다.

그렇다면 2개의 값에 대하여 각자 할당된 Rank 들의 값을 더해줄 수 있습니다. 

Group1: 5 + 6 + 7 + 8 = 26

Group2: 1 + 2 + 3 + 4 = 10

요렇게요.

이렇게 두 그룹의 각 Rank 를 Sum 한 값을 통해 분석하기에, Wilcoxon Sum Rank Test 입니다.

그렇다면 어떤 통계값을 얻을 수 있을까요?

				
					u_statistic <- min(u1, u2)


				
			

우선 U-통계량입니다.

R 코드에서는 위와 같은 계산 방식을 가지게 됩니다.

u1 과 u2가 아까 위에서 계산한 각 그룹의 랭크 합계가 됩니다.

두 개의 랭크 합계 중에서 더 작은 값을 택하는 거죠.

제가 제시한 가상의 데이터로 U-통계량을 구하자면, 10이 되겠군요. 

				
					# 샘플 크기 계산
n1 <- sum(data$Group == "Group1")
n2 <- sum(data$Group == "Group2")

# Expected U 계산
expected_u <- (n1 * n2) / 2
				
			

이번에는 U-기대값입니다.

각 그룹의 개수를 곱하고 2로 나눈 값입니다. 

만약 각 그룹의 점수가 거의 비슷하다고 할 때, 기대하게 되는 평균 순위의 합이 됩니다. 

				
					sd_u <- sqrt((n1 * n2 * (n1 + n2 + 1)) / 12)
				
			

샘플의 크기를 통해서 SD도 구할 수 있습니다.

sqrt 는 제곱근. 루트입니다.

				
					# Z-value 계산
z_value <- (u_statistic - expected_u) / sd_u
				
			

위에서 구했던 U-통계량, U-기대값, SD를 이용하여 Z-value 도 구할 수 있습니다.

Z-value가 클수록 두 그룹 간의 차이가 크다는 것을 의미합니다.

R 스크립트

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

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

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

# 인자 설정
# 읽어낼 데이터프레임의 경로
filename <- args_list$filename
# 데이터프레임에서 이진 변수에 해당하는 Column
GroupVar <- args_list$GroupVar
# 데이터프레임에서 연속 변수에 해당하는 Column
ValueVar <- args_list$ValueVar
# 생성된 결과 파일을 저장할 경로
goalname <- args_list$goalname

# 데이터 불러오기
data <- read.csv(filename)

# 결측치 처리 후 data 에 다시 담기
data <- data[!is.na(data[[GroupVar]]) & !is.na(data[[ValueVar]]), ]

# 연속 변수에 해당하는 열의 데이터를 체크, 숫자로 변환
if (!is.numeric(data[[ValueVar]])) {
  data[[ValueVar]] <- as.numeric(data[[ValueVar]])
}

# 범주형 이진 변수에 해당하는 열의 데이터를 체크, 범주형임을 명시
if (!is.factor(data[[GroupVar]])) {
  data[[GroupVar]] <- as.factor(data[[GroupVar]])
}

# Wilcoxon Rank-Sum Test 수행
test_result <- wilcox.test(data[[ValueVar]] ~ data[[GroupVar]])

# 추가 통계량 계산
n1 <- sum(data[[GroupVar]] == levels(data[[GroupVar]])[1])
n2 <- sum(data[[GroupVar]] == levels(data[[GroupVar]])[2])
r1 <- sum(rank(data[[ValueVar]])[data[[GroupVar]] == levels(data[[GroupVar]])[1]])
r2 <- sum(rank(data[[ValueVar]])[data[[GroupVar]] == levels(data[[GroupVar]])[2]])

u1 <- r1 - (n1 * (n1 + 1) / 2)
u2 <- r2 - (n2 * (n2 + 1) / 2)
u_statistic <- min(u1, u2)

# Expected U and Standard Deviation
expected_u <- (n1 * n2) / 2
sd_u <- sqrt((n1 * n2 * (n1 + n2 + 1)) / 12)

# Z-value 계산
z_value <- (u_statistic - expected_u) / sd_u

# 결과 요약
test_summary <- data.frame(
  U_Statistic = u_statistic,
  Expected_U = expected_u,
  SD = sd_u,
  Z_value = z_value,
  P_value = test_result$p.value
)

# 결과 시각화
# 상자 그림(Boxplot)으로 시각화
boxplot_data <- data.frame(
  Group = data[[GroupVar]],
  Value = data[[ValueVar]]
)

boxplot <- ggplot(boxplot_data, aes(x = Group, y = Value, fill = Group)) +
  geom_boxplot() +
  theme_minimal() +
  labs(title = "Wilcoxon Rank-Sum Test Results",
       x = NULL, y = "Values") +
  scale_x_discrete(labels = levels(data[[GroupVar]]))

# 통계량 텍스트
stat_text <- paste(
  "Sample sizes (n1, n2): ", n1, ", ", n2, "\n",
  "U Statistic: ", round(u_statistic, 2), "\n",
  "Expected U: ", round(expected_u, 2), "\n",
  "SD: ", round(sd_u, 2), "\n",
  "Z-value: ", round(z_value, 2), "\n",
  "P-value: ", round(test_result$p.value, 4)
)

# P-value와 통계량 텍스트
text_plot <- ggplot() + 
  annotate("text", x = 1, y = 1, label = stat_text, size = 6) +
  theme_void()

# 테이블과 플롯을 결합하여 저장
lay <- matrix(c(1, 2), nrow = 1)

# SVG 파일로 저장 (가로 길이를 넓게 설정)
svglite::svglite(file = goalname, width = 10, height = 7)
grid.arrange(boxplot, text_plot, layout_matrix = lay)
dev.off()

				
			

우선 전체 코드입니다. 

어떤 식으로 데이터를 구했는지 코드를 둘러보겠습니다.

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

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

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

# 인자 설정
# 읽어낼 데이터프레임의 경로
filename <- args_list$filename
# 데이터프레임에서 이진 변수에 해당하는 Column
GroupVar <- args_list$GroupVar
# 데이터프레임에서 연속 변수에 해당하는 Column
ValueVar <- args_list$ValueVar
# 생성된 결과 파일을 저장할 경로
goalname <- args_list$goalname
				
			

해당 코드를 사용하기 위해서 필요한 인자들을 코드 앞 부분에서 파싱하여 저장합니다. 

				
					# 데이터 불러오기
data <- read.csv(filename)

# 결측치 처리 후 data 에 다시 담기
data <- data[!is.na(data[[GroupVar]]) & !is.na(data[[ValueVar]]), ]
				
			

지정된 경로를 이용하여 데이터를 읽어옵시다. 

data[조건, “A”] <- 값: 조건에 맞는 행의 “A” 열 값을 지정된 값으로 변경합니다.

data[조건, ]: 조건에 맞는 행을 선택합니다.

!is.na(열): 해당 열에 결측치가 아닌 값을 선택합니다.

위 방법들이 활용한 것입니다.

조건에 맞는 행들을 캐치해서 data 에 다시 할당을 하는 것이죠. 

 

				
					# 연속 변수에 해당하는 열의 데이터를 체크, 숫자로 변환
if (!is.numeric(data[[ValueVar]])) {
  data[[ValueVar]] <- as.numeric(data[[ValueVar]])
}

# 범주형 이진 변수에 해당하는 열의 데이터를 체크, 범주형임을 명시
if (!is.factor(data[[GroupVar]])) {
  data[[GroupVar]] <- as.factor(data[[GroupVar]])
}
				
			

연속형 변수에 해당하는 열이 정말 numeric 인지 확인합시다.

is.numeric() 을 이용해서 확인합니다. 만약 아니라면, 연속형으로 바꿔주는 거죠.

as.numeric() 을 이용해서 연속형으로 변경. 변경된 데이터를 다시 data[[ValueVar]] 에 할당합니다.

마찬가지로 범주형도 같은 작업을 할 겁니다.

다만, is.numeric() 대신에 is.factor() 을 이용해서 범주형인지 확인을 합니다.

이후, as.factor() 을 이용해서 범주형임을 명시합니다. 

 

				
					# Wilcoxon Rank-Sum Test 수행
test_result <- wilcox.test(data[[ValueVar]] ~ data[[GroupVar]])
				
			

이제 wicoxon rank sum 테스트를 수행합니다.

반드시 ValueVar 과 GroupVar 이라는 Column 이 데이터 내에 존재하여야 합니다. 

~의 왼쪽에는 비교할 연속형 데이터 (측정값)가 있어야 합니다.

~의 오른쪽에는 그룹을 나타내는 범주형 데이터 (이진 변수 또는 범주형 변수)가 있어야 합니다.

 

				
					# 추가 통계량 계산
n1 <- sum(data[[GroupVar]] == levels(data[[GroupVar]])[1])
n2 <- sum(data[[GroupVar]] == levels(data[[GroupVar]])[2])
r1 <- sum(rank(data[[ValueVar]])[data[[GroupVar]] == levels(data[[GroupVar]])[1]])
r2 <- sum(rank(data[[ValueVar]])[data[[GroupVar]] == levels(data[[GroupVar]])[2]])

u1 <- r1 - (n1 * (n1 + 1) / 2)
u2 <- r2 - (n2 * (n2 + 1) / 2)
u_statistic <- min(u1, u2)

# Expected U and Standard Deviation
expected_u <- (n1 * n2) / 2
sd_u <- sqrt((n1 * n2 * (n1 + n2 + 1)) / 12)

# Z-value 계산
z_value <- (u_statistic - expected_u) / sd_u

# 결과 요약
test_summary <- data.frame(
  U_Statistic = u_statistic,
  Expected_U = expected_u,
  SD = sd_u,
  Z_value = z_value,
  P_value = test_result$p.value
)

# 결과 시각화
# 상자 그림(Boxplot)으로 시각화
boxplot_data <- data.frame(
  Group = data[[GroupVar]],
  Value = data[[ValueVar]]
)

boxplot <- ggplot(boxplot_data, aes(x = Group, y = Value, fill = Group)) +
  geom_boxplot() +
  theme_minimal() +
  labs(title = "Wilcoxon Rank-Sum Test Results",
       x = NULL, y = "Values") +
  scale_x_discrete(labels = levels(data[[GroupVar]]))

# 통계량 텍스트
stat_text <- paste(
  "Sample sizes (n1, n2): ", n1, ", ", n2, "\n",
  "U Statistic: ", round(u_statistic, 2), "\n",
  "Expected U: ", round(expected_u, 2), "\n",
  "SD: ", round(sd_u, 2), "\n",
  "Z-value: ", round(z_value, 2), "\n",
  "P-value: ", round(test_result$p.value, 4)
)


# P-value와 통계량 텍스트
text_plot <- ggplot() + 
  annotate("text", x = 1, y = 1, label = stat_text, size = 6) +
  theme_void()

# 테이블과 플롯을 결합하여 저장
lay <- matrix(c(1, 2), nrow = 1)

# SVG 파일로 저장 (가로 길이를 넓게 설정)
svglite::svglite(file = goalname, width = 10, height = 7)
grid.arrange(boxplot, text_plot, layout_matrix = lay)
dev.off()
				
			

이후 계산은 앞서 설명했던 내용과 동일하며,

생산한 계산값을 어떤 형식의 Plot으로 저장할지는 직접 디자인을 찾아 활용해도 좋습니다. 

백엔드 내에서의 코드

위와 같이 R 스크립트를 EC2 내의 특정 경로에 저장했다면, 

이제 백엔드 내에서 해당 R 스크립트를 사용하기 위한 전용 함수를 하나 만들 겁니다.

				
					function runWilcoxonSum(res, filename, groupVar, valueVar, goalname) {
  const scriptPath = "your script.R";
  // Node.js의 child_process.spawn을 사용하여 R 스크립트를 비동기적으로 실행합니다.
  // 스크립트에 필요한 모든 파라미터를 명령줄 인수로 전달합니다.
  const rProcess = spawn("Rscript", [
    scriptPath,
    //filename 에는 분석에 사용한 CSV 데이터프레임의 경로를 넣습니다. ex."/home/data/mydata.csv"
    `filename=${filename}`,
    //Wilcoxon Signed Rank 에서 종속변수로 사용할 첫 번째 값입니다. 어떠한 이벤트의 이전이라고 보아도 좋습니다.
    `GroupVar=${groupVar}`,
    //Wilcoxon Signed Rank 에서 종속변수로 사용할 두 번째 값입니다. 어떠한 이벤트의 이후라고 보아도 좋습니다.
    `ValueVar=${valueVar}`,
    //결과를 어느 곳으로 저장할지 .
    `goalname=${goalname}`,
  ]);

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

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

  // R 프로세스가 종료되면 호출되는 이벤트 핸들러입니다.
  rProcess.on("close", (code) => {
    // R 스크립트의 종료 코드를 로깅합니다.
    console.log(`R process exited with code ${code}`);

    // 종료 코드가 0이 아닌 경우, 스크립트 실행이 실패했다는 것을 의미합니다.
    if (code !== 0) {
      console.error("R script execution failed with code:", code);
      return res.status(500).send("R script execution failed");
    }
    try {
      // 결과 이미지 파일의 경로를 지정합니다.
      const imgPath = goalname;
      // 파일 시스템에서 이미지 파일을 비동기적으로 읽습니다.
      fs.readFile(imgPath, (err, data) => {
        if (err) {
          // 파일 읽기 오류가 발생한 경우, 에러를 로깅하고 클라이언트에 오류 메시지를 전송합니다.
          console.error("Error reading image file:", err);
          return res.status(500).send("Error reading image file");
        }
        // 이미지 데이터를 성공적으로 읽은 경우, HTTP 응답으로 이미지를 전송합니다.
        res.writeHead(200, { "Content-Type": "image/svg+xml" });
        res.end(data);
      });
    } catch (err) {
      // 이미지 전송 중 예외가 발생한 경우, 에러를 로깅하고 오류 메시지를 전송합니다.
      console.error("Failed to send image:", err);
      res.status(500).send("Failed to send image");
    }
  });
}
				
			

위와 같은 함수를 백엔드 코드 내부에 저장해두면, 언제든지 해당 함수에 알맞은 변수를 할당하여 코드를 실행 가능합니다.

				
					runWilcoxonSum(res, filename, groupVar, valueVar, goalname);
				
			

위와 같이 말이죠. 

마치며...

이제 특정한 변수를 이용하여 백엔드에서 Wilcoxon Rank Sum Test 를 돌리고, 그 결과를 저장 가능합니다.