Chi Square Test를 백엔드에서 사용해보자

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

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

Chi Square Test 란

성별TV 시청 빈도
남성매일
여성주 1~2회
남성주 1~2회
여성매일
남성주 1~2회
여성월 1~2회
남성월 1~2회
여성주 1~2회
남성주 1~2회
여성매일

먼저 위와 같은 가상의 데이터를 하나 만들겠습니다.

위 데이터의 “성별” “TV 시청 빈도” 는 모두 “범주형 변수” 입니다.

기존에 특정 범주형 변수에 따라 연속형 변수를 분석하는 기법과 다르게, Chi Square Test 는 두 범주형 변수의 통계적 관계를 확인하기 위해 존재하는 기법입니다.

위 데이터를 새롭게 변형해볼까요?

성별매일주 1~2회월 1~2회
남성131
여성221

기존에 문자열만 있던 데이터를 위와 같이 변경이 가능할 겁니다.

성별이 남자인 경우, 다른 범주형 변수의 데이터가 몇 개씩 있는지 그 빈도를 표시하는 것입니다.

이를 통해 우리는 기대 빈도를 계산할 수 있습니다. 

기대 빈도를 구하는 공식입니다.

예를 들어서, “남성 – 매일” 셀의 기대 빈도는 다음과 같이 계산을 할 수 있습니다.

총합이란 빈도의 총합을 생각하면 됩니다.

여성과 남성의 빈도를 모두 더한 값이 분모가 되며, 본자는 남성 쪽의 총합과, 매일의 총합의 곱이 들어갑니다.

매일에 해당하는 빈도는 총 3개, 남성에 해당하는 빈도의 총합은 총 5이군요 .

이를 통해 계산하면 기대 빈도는 1.5가 됩니다. 

그럼 이제 카이제곱 통계량을 계산할 수 있습니다.

분모는 “기대 빈도” 가 들어가며, 분자는 해당 셀의 빈도 수에서 기대 빈도를 감산한 값을 제곱하고 있습니다.

예컨대 “남성 = 매일” 셀에 대해 통계량을 구해보겠습니다.

앞서 “남성 – 매일” 셀의 기대 빈도는 1.5 였습니다.

또한 남성에 해당하는 매일의 빈도수는 1입니다. 즉, 분모는 (1-1.5)의 제곱이 됩니다.

이를 계산하면 해당 셀의 통계량은 0.167이 되는 것입니다.

이것을 모든 셀에 대해서 실행을 할 수 있습니다.

또한 이 모든 셀의 통계량을 합산하면 0.534가 됩니다.

또한 자유도 역시 빈도수 테이블을 이용하여 구할 수 있습니다.

“남성, 여성” 이라는 행을 가지고, “매일, 주 1~2회, 월 1~2회” 라는 열을 가진 데이터 테이블을 이용한다면…

위와 같이 나타낼 수 있습니다.

마지막으로 유의 수준과 임계값을 비교 가능하겠죠?

앞서 우리의 카이제곱 값이 0.534 라는 통계량으로 결과값이 나타났습니다.

유의수준 0.05에 해당하는 임계값은 0.5991입니다.

0.534는 0.5991을 넘지 못하죠?

즉, 귀무가설을 기각하지 않는다는 뜻입니다.

따라서 성별과 TV 시청 빈도 간에 통계적으로 유의미한 관계는 없다는 뜻이 됩니다. 

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)

# 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]]), ]

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

# 이제부터 ValueList 에 할당된 열들을 순회하며 작업.
# 그룹에 따라 분석이 필요한 값들을 차근차근 분석.
for (value in ValueList) {
  # 분석이 필요한 해당 열의 결측치를 제거 후, 다시 데이터에 할당.
  data_sub <- data[!is.na(data[[value]]), ]
  
  # 범주형 변수인지 확인
  if (!is.factor(data_sub[[value]])) {
    data_sub[[value]] <- as.factor(data_sub[[value]])
  }
  
  # 필터링 결과가 비어 있는지 확인
  if (nrow(data_sub) == 0) {
    next  # 비어 있는 경우 다음 변수로 건너뜁니다.
  }
  
  # 카이제곱 검정 수행
  table_data <- table(data_sub[[Group]], data_sub[[value]])
  test_result <- chisq.test(table_data)
  
  # 결과 저장 (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
  )
  
  # 그래프 생성
  observed <- as.data.frame(table_data)
  expected <- as.data.frame(test_result$expected)
  colnames(observed) <- c("Group", "Value", "Observed")
  colnames(expected) <- c("Group", "Value", "Expected")
  plot_data <- merge(observed, expected)
  
  p <- ggplot(plot_data, aes(x = Group, y = Observed, fill = Value)) +
    geom_bar(stat = "identity", position = "dodge") +
    geom_point(aes(y = Expected), color = "red", position = position_dodge(width = 0.9), size = 3) +
    ggtitle(paste("Chi-Square Test for", value, "by", Group)) +
    xlab(Group) +
    ylab("Count") +
    theme_minimal()
  
  # SVG 파일로 저장
  svg_filename <- paste0(sub(".svg", "", graphicgoal), "_", value, ".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)


				
			

우선 사용하기 위한 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)

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

				
			

filename 이라는 인자는 분석에 사용한 데이터프레임의 경로입니다.

이 경로를 통해 데이터를 읽고 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))
  }
}

				
			

Values 는 “A,B,C”와 같이 , 이 포함된 문자열로 되어 있습니다.

이것을 , 기호를 통해서 나누고 리스트로 지정합니다.

이 리스트를 순회하면 A, B, C 인자를 하나하나 사용할 수 있게 됩니다.

리스트 순회를 하면서 해당 변수값이 데이터의 열로 존재하는지 확인하고 없다면 코드를 중지합니다. 

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

				
			

분석을 시행하기 이전에 기준이 되는 범주형 변수의 열이 실제 범주형으로 인식되고 있는지 확인합니다.

만약 아니라면, as.factor 방법을 이용해서 해당 열의 데이터를 범주형으로 변경합시다.

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

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

				
			

data[조건, ] 을 사용하면 해당 조건에 해당하는 행들을 data 내에서 모두 선택 가능합니다.

즉, group 의 데이터 중에서 결측치가 아닌 행들만 모조리 선택하는 겁니다.

선택된 행들을 data 내에 다시 할당을 해줍시다. 이러면 기준이 되는 범주형의 결측치 행들을 data 내에서 모두 삭제합니다.

이후 results 라는 빈 리스트를 하나 생성합니다.

이곳에 ValueList 에 있는 변수와 각각 비교한 결과를 담을 계획입니다.

				
					# 이제부터 ValueList 에 할당된 열들을 순회하며 작업.
# 그룹에 따라 분석이 필요한 값들을 차근차근 분석.
for (value in ValueList) {
  # 분석이 필요한 해당 열의 결측치를 제거 후, 다시 데이터에 할당.
  data_sub <- data[!is.na(data[[value]]), ]
  
  # 범주형 변수인지 확인
  if (!is.factor(data_sub[[value]])) {
    data_sub[[value]] <- as.factor(data_sub[[value]])
  }
  
  # 필터링 결과가 비어 있는지 확인
  if (nrow(data_sub) == 0) {
    next  # 비어 있는 경우 다음 변수로 건너뜁니다.
  }
  
  # 카이제곱 검정 수행
  table_data <- table(data_sub[[Group]], data_sub[[value]])
  test_result <- chisq.test(table_data)
  
  # 결과 저장 (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
  )
  
  # 그래프 생성
  observed <- as.data.frame(table_data)
  expected <- as.data.frame(test_result$expected)
  colnames(observed) <- c("Group", "Value", "Observed")
  colnames(expected) <- c("Group", "Value", "Expected")
  plot_data <- merge(observed, expected)
  
  p <- ggplot(plot_data, aes(x = Group, y = Observed, fill = Value)) +
    geom_bar(stat = "identity", position = "dodge") +
    geom_point(aes(y = Expected), color = "red", position = position_dodge(width = 0.9), size = 3) +
    ggtitle(paste("Chi-Square Test for", value, "by", Group)) +
    xlab(Group) +
    ylab("Count") +
    theme_minimal()
  
  # SVG 파일로 저장
  svg_filename <- paste0(sub(".svg", "", graphicgoal), "_", value, ".svg")
  svglite::svglite(file = svg_filename, width = 8, height = 6)
  print(p)
  dev.off()
}
				
			

ValueList를 순회하며 ValueList 내부에 있는 범주형 변수와, Group 으로 설정한 기준이 되는 범주형 변수를 1:1 로 분석하고 그 통계 결과를 저장하는 로직입니다.

위 순회문을 해체하여 차근차근 보겠습니다.

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

처음으로 받은 인자를 A라고 가정할까요?

우선 A라는 열에서 결측치를 제거합니다.

이미 data는 Group 이라는 기준 범주형의 결측치가 제거된 상태였죠?

여기서 A라는 열의 결측치도 제거함으로써, 기준이 되는 범주형과 비교를 위한 범주형의 결측치를 처리합니다.

처리된 데이터는 data_sub 라는 곳에 담습니다.

				
					  # 범주형 변수인지 확인
  if (!is.factor(data_sub[[value]])) {
    data_sub[[value]] <- as.factor(data_sub[[value]])
  }
  
  # 필터링 결과가 비어 있는지 확인
  if (nrow(data_sub) == 0) {
    next  # 비어 있는 경우 다음 변수로 건너뜁니다.
  }
				
			

앞서 사용한 로직과 동일하게 A라는 열이 진짜 범주형인지 확인하고, 아니라면 범주형으로 지정합니다.

생성된 데이터에 행이 없다면 다음 변수로 건너갑니다. 

				
					  # 카이제곱 검정 수행
  table_data <- table(data_sub[[Group]], data_sub[[value]])
  test_result <- chisq.test(table_data)
				
			

카에제곱 검정 수행을 수행하기 전에 데이터를 변형할 필요가 있습니다.

기준이 되는 범주형 데이터를 행으로, 비교를 위한 범주형 데이터를 열로 두기 위해서,

table(“기준 범주형”, “비교군 범주형”) 방법을 이용하여 table_data 로 변형합니다.

이후 chisq.test(table_data)를 사용하면 결과를 얻을 수 있습니다. 

				
					  # 결과 저장 (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
  )
  
				
			

테스트 결과에서 필요한 값들을 results 내부에 담습니다.

이때 A에 대해 수행하면, Key 값은 A가 됩니다. 

				
					{
"A" : [sattistics, p-value ...]
}
  
				
			

요런 식으로 담기게 됩니다.

그리고 시각화 코드입니다. 

				
					  # 그래프 생성
  observed <- as.data.frame(table_data)
  expected <- as.data.frame(test_result$expected)
  colnames(observed) <- c("Group", "Value", "Observed")
  colnames(expected) <- c("Group", "Value", "Expected")
  plot_data <- merge(observed, expected)
  
  p <- ggplot(plot_data, aes(x = Group, y = Observed, fill = Value)) +
    geom_bar(stat = "identity", position = "dodge") +
    geom_point(aes(y = Expected), color = "red", position = position_dodge(width = 0.9), size = 3) +
    ggtitle(paste("Chi-Square Test for", value, "by", Group)) +
    xlab(Group) +
    ylab("Count") +
    theme_minimal()
  
  # SVG 파일로 저장
  svg_filename <- paste0(sub(".svg", "", graphicgoal), "_", value, ".svg")
  svglite::svglite(file = svg_filename, width = 8, height = 6)
  print(p)
  dev.off()
}
				
			

여기는 사실 앞서 통계를 낸 test_result 값에서 어떤 값을 사용하여, 어떻게 나타낼지 개인의 입맛에 맞게 수정하시면 됩니다.

SVG 파일로 저정하는 부분만 주의하시면 될 것 같습니다. 

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

이후 순회문 바깥에서는 모든 key 값에 그 결과가 기록된 results 라는 리스트를 json 형태로 변경하여 저장합니다. 

백엔드 함수

				
					function runChiSquare(
  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");
      }
    }
  });
}
				
			

위쪽에 제작한 스크립트의 경로를 9번 줄에 지정하면 됩니다.

그럼 다음의 함수에 원하는 인자를 할당하여 해당 스크립트를 사용 가능합니다. 

마치며...

이런 식으로 results를 순회하는 방식을 이용해 테이블도 만들 수 있겠죠.

생성된 데이터를 활용하여 데이터를 원하는 방식으로 나열해보세요.