자료·리서치

설문 CSV를 집계하기 전에 응답수·누락·중복 점검하기

합성 응답 10행에서 중복 ID와 빈 답변을 구분합니다. 유효 응답의 분모를 명시하고 선택지별 개수와 비율, 제외 사유를 새 파일에 남깁니다.

목차 보기

이런 분께설문을 CSV로 내보낸 뒤 응답 수와 백분율을 직접 확인하고 싶은 입문자

준비사항
  • Python 3.12 이상을 준비하고 python --version으로 실행기를 확인합니다. 추가 패키지는 필요하지 않습니다.
  • 예제 ZIP을 새 폴더에 압축 해제하고 example.py, responses.csv, README.txt가 함께 있는지 확인합니다.
  • example.py가 보이는 폴더에서 터미널을 엽니다. Windows에서는 환경에 따라 py, macOS·Linux에서는 python3 명령을 사용할 수 있습니다.
  • 연습 자료는 실제 응답자가 없는 합성 데이터입니다. R001 같은 ID는 이 예제를 위해 직접 작성했습니다.

01응답 열 개를 먼저 눈으로 읽기

설문 파일의 행 수를 그대로 응답자 수라고 쓰면 중복 제출이 포함될 수 있습니다. 빈 답변을 분모에 넣는지에 따라서도 같은 선택지의 비율이 달라집니다. 이 예제에서는 ‘다시 이용할 의향이 있나요?’라는 한 문항을 사용해 집계 전에 세울 규칙을 연습합니다. 실행보다 먼저 원본에서 중복과 빈 답변을 찾아보세요.

  1. responses.csv를 텍스트 편집기나 CSV를 읽는 프로그램으로 엽니다. 첫 줄은 respondent_id,answer 두 열입니다.
  2. 헤더를 빼고 데이터가 10행인지 세어 봅니다. R005가 두 번 등장하고 R004와 R009의 답변이 비어 있는지 확인합니다.
  3. example.py가 있는 폴더의 터미널에서 아래 명령을 실행합니다.
  4. outputs에 생성된 summary.json, choices.csv, audit_rows.csv를 차례로 엽니다.
bash
python example.py

코드는 example.py가 있는 위치를 기준으로 responses.csv를 찾습니다. 출력은 별도 outputs에 기록하고 입력은 바꾸지 않습니다. 다른 작업 폴더에서 스크립트 경로로 실행하더라도 같은 예제 입력을 읽습니다.

02중복 처리 규칙과 두 분모 구분하기

R005는 ‘예’와 ‘아니오’를 각각 제출했습니다. 이 예제는 처음 값이나 마지막 값을 임의로 선택하지 않고, 두 번 이상 나온 ID의 모든 행을 집계에서 제외합니다. 어느 응답이 유효한지 판단할 정보가 없기 때문입니다. 이 정책은 코드와 요약 파일의 duplicate_policy에 명시되어 있습니다.

수치계산예제 결과
원시 데이터 행헤더를 제외한 읽은 행10행
고유 ID같은 ID를 한 번만 셈9개
중복으로 제외한 행R005의 모든 행2행
남은 고유 응답자10 − 28명
유효 응답남은 8명 − 빈 답변 2명6명

응답 완료 비율은 남은 고유 응답자 8명 중 유효 답변 6명의 비율인 75.00%입니다. 각 선택지의 비율은 유효 답변 6명을 분모로 삼습니다. 고유 ID 9개를 그대로 분모로 쓰지 않는 이유는 R005의 응답 전체를 분석에서 제외했기 때문입니다.

03점검과 집계가 들어 있는 전체 코드

read_rows는 열과 ID, 선택지를 검사합니다. summarize는 ID 빈도를 먼저 센 뒤 행마다 duplicate_id, blank_answer, valid 중 하나의 상태를 붙입니다. 중복 행을 먼저 판정하기 때문에 같은 행을 중복 제외와 빈 응답 제외에 이중으로 더하지 않습니다.

example.py
"""설문 합성 CSV의 중복 ID와 무응답을 점검한 뒤 유효 응답을 집계합니다."""

import csv
import json
import re
import sys
from collections import Counter
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path

BASE = Path(__file__).resolve().parent
INPUT = BASE / "responses.csv"
OUTPUT = BASE / "outputs"
CHOICES = ["예", "아니오", "잘 모르겠음"]


def percent(numerator, denominator):
    # 분모가 0이면 0%로 오해하지 않도록 계산 불가를 None으로 남깁니다.
    if denominator == 0:
        return None
    value = Decimal(numerator) * Decimal(100) / Decimal(denominator)
    return str(value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))


def read_rows():
    if INPUT.is_symlink() or not INPUT.is_file():
        raise ValueError("responses.csv는 일반 파일이어야 합니다.")
    rows = []
    with INPUT.open("r", encoding="utf-8-sig", newline="") as stream:
        reader = csv.DictReader(stream, strict=True)
        if reader.fieldnames != ["respondent_id", "answer"]:
            raise ValueError("열 이름과 순서는 respondent_id,answer여야 합니다.")
        for number, row in enumerate(reader, start=1):
            if None in row or any(value is None for value in row.values()):
                raise ValueError(f"데이터 {number}행: 열 수를 확인하세요.")
            respondent_id, answer = row["respondent_id"].strip(), row["answer"].strip()
            if not re.fullmatch(r"R[0-9]{3}", respondent_id):
                raise ValueError(f"데이터 {number}행: ID는 R001처럼 R과 숫자 세 자리여야 합니다.")
            if answer and answer not in CHOICES:
                raise ValueError(f"데이터 {number}행: 알 수 없는 응답 {answer!r}")
            rows.append({"data_row": number, "respondent_id": respondent_id, "answer": answer})
    return rows


def summarize(rows):
    frequencies = Counter(row["respondent_id"] for row in rows)
    duplicate_ids = sorted(key for key, count in frequencies.items() if count > 1)
    duplicates = set(duplicate_ids)
    counts = Counter()
    audit, blank_after_duplicates = [], 0
    for row in rows:
        # 중복 그룹에서는 첫 응답이나 마지막 응답을 임의로 고르지 않습니다.
        if row["respondent_id"] in duplicates:
            status = "duplicate_id"
        elif not row["answer"]:
            status = "blank_answer"
            blank_after_duplicates += 1
        else:
            status = "valid"
            counts[row["answer"]] += 1
        audit.append({**row, "status": status})

    duplicate_rows = sum(frequencies[key] for key in duplicate_ids)
    eligible = len(rows) - duplicate_rows  # 여기에는 빈 답변을 한 고유 ID도 포함됩니다.
    valid = sum(counts.values())
    summary = {
        "question": "다시 이용할 의향이 있나요?",
        "duplicate_policy": "exclude_all_rows_with_duplicate_id",
        "raw_rows": len(rows),
        "unique_ids": len(frequencies),
        "duplicate_ids": duplicate_ids,
        "duplicate_id_count": len(duplicate_ids),
        "excluded_duplicate_rows": duplicate_rows,
        "raw_blank_answer_rows": sum(not row["answer"] for row in rows),
        "eligible_unique_respondents": eligible,
        "blank_answer_rows_after_duplicates": blank_after_duplicates,
        "valid_answer_rows": valid,
        "completion_rate_percent": percent(valid, eligible),
        "choices": [
            {"answer": answer, "count": counts[answer], "denominator": valid,
             "percent": percent(counts[answer], valid)} for answer in CHOICES
        ],
    }
    return summary, audit


def main():
    if OUTPUT.exists() or OUTPUT.is_symlink():
        raise FileExistsError("outputs가 이미 있습니다. 기존 결과를 옮긴 뒤 실행하세요.")
    summary, audit = summarize(read_rows())  # 입력 검증과 집계를 마친 뒤 출력합니다.
    OUTPUT.mkdir()
    with (OUTPUT / "summary.json").open("x", encoding="utf-8") as stream:
        json.dump(summary, stream, ensure_ascii=False, indent=2)
        stream.write("\n")
    with (OUTPUT / "choices.csv").open("x", encoding="utf-8-sig", newline="") as stream:
        writer = csv.DictWriter(stream, fieldnames=["answer", "count", "denominator", "percent"])
        writer.writeheader()
        writer.writerows(summary["choices"])
    with (OUTPUT / "audit_rows.csv").open("x", encoding="utf-8-sig", newline="") as stream:
        writer = csv.DictWriter(stream, fieldnames=["data_row", "respondent_id", "answer", "status"])
        writer.writeheader()
        writer.writerows(audit)
    print(f"원시 {summary['raw_rows']}행 / 고유 ID {summary['unique_ids']}개")
    print(f"중복 제외 {summary['excluded_duplicate_rows']}행 / 남은 고유 응답자 {summary['eligible_unique_respondents']}명")
    print(f"빈 응답 {summary['blank_answer_rows_after_duplicates']}행 / 유효 응답 {summary['valid_answer_rows']}행")
    if not summary["valid_answer_rows"]:
        print("유효 응답이 없어 선택지 비율을 계산하지 않았습니다.")
    print(f"완료: {OUTPUT}")
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except (OSError, ValueError, csv.Error) as error:
        print(f"중지: {error}", file=sys.stderr)
        raise SystemExit(2)

Counter로 ID별 등장 횟수와 선택지 개수를 세고 Decimal로 백분율을 두 자리까지 반올림합니다. ID와 답변의 앞뒤 공백은 제거합니다. 알 수 없는 선택지를 조용히 버리지 않고 중지하며, 모든 입력 검사가 끝난 다음에만 출력 폴더를 만듭니다.

04선택지별 개수와 비율 확인하기

선택지개수분모백분율
3650.00%
아니오2633.33%
잘 모르겠음1616.67%

‘예’는 R001, R003, R007의 세 응답입니다. R005의 ‘예’는 제외되었으므로 네 개가 아닙니다. ‘아니오’는 R002와 R008 두 응답이고 ‘잘 모르겠음’은 R006 한 응답입니다. 세 선택지의 개수를 더한 6이 valid_answer_rows와 같아야 합니다.

choices.csv는 answer, count, denominator, percent 네 열을 제공합니다. percent에는 % 기호 없이 50.00 같은 숫자 문자열을 기록합니다. 이를 표에 옮길 때는 이미 100을 곱한 백분율이라는 점을 확인하세요. 스프레드시트의 백분율 형식을 그대로 적용해 5000%로 만들지 않도록 주의합니다.

05제외 사유를 원본 행까지 따라가기

파일확인할 내용
summary.json원시 행·고유 ID·제외 행·두 분모·비율
choices.csv선택지별 개수와 유효 응답 분모
audit_rows.csv데이터 행 순서·ID·답변·판정 상태
  1. audit_rows.csv에서 data_row 5와 6이 duplicate_id인지 확인합니다. 둘 다 R005여야 합니다.
  2. data_row 4와 10이 blank_answer인지 확인합니다. ID는 R004와 R009입니다.
  3. 나머지 여섯 행의 상태가 valid인지 확인하고 그 개수를 summary.json과 맞춥니다.
  4. 원본 responses.csv가 그대로인지 확인합니다. 같은 명령을 다시 실행하면 기존 outputs를 보존하며 중지해야 합니다.

data_row는 헤더를 제외하고 읽힌 데이터의 순서입니다. 물리적인 빈 줄은 CSV 읽기에서 건너뛰므로 텍스트 편집기의 줄 번호와 항상 같지는 않습니다. 무응답을 표현할 때는 완전히 빈 줄이 아니라 R004,처럼 ID가 있고 answer만 빈 행을 사용합니다.

06분모가 바뀌는 작은 실험

연습을 반복할 때는 첫 결과를 다른 이름으로 옮겨 보관하거나 ZIP을 새 폴더에 풉니다. R004의 빈 답변을 ‘예’로 바꾼 사본을 실행해 보세요. 중복 제외 후 응답자 수는 여전히 8명이고 유효 응답은 7명입니다. ‘예’는 4/7 = 57.14%, 응답 완료 비율은 7/8 = 87.50%가 되어야 합니다.

모든 고유 응답자의 answer를 빈 칸으로 만드는 경우도 생각해 볼 수 있습니다. 남은 응답자가 있지만 유효 답변이 0이면 응답 완료 비율은 0.00%입니다. 반면 선택지 비율의 분모는 0이므로 계산하지 않습니다. JSON에는 null, CSV에는 빈 칸을 기록해 0%와 구별합니다.

07집계 전에 고쳐야 하는 입력 오류

중지 원인확인 방법다음 행동
잘못된 열 이름 또는 순서첫 줄이 respondent_id,answer인지 확인내보낸 사본의 열을 규칙에 맞춥니다.
빈 ID 또는 형식 불일치ID가 R과 숫자 세 자리인지 확인합성 예제 규칙에 맞게 ID를 수정합니다.
알 수 없는 답변예·아니오·잘 모르겠음 외 값 확인오타인지 새 선택지인지 원자료에서 확인합니다.
열 수 불일치쉼표 추가 또는 누락 확인CSV 구조를 복구한 사본으로 실행합니다.
outputs가 이미 있음이전 결과 폴더 존재 확인결과를 다른 이름으로 보관한 뒤 재실행합니다.

입력 오류가 나면 출력 폴더를 만들기 전에 중지합니다. 오류를 피하려고 해당 행부터 삭제하면 제외한 근거가 사라집니다. 원본을 남겨 두고 사본의 수정 사항과 규칙을 기록한 뒤 다시 실행하세요. 중복 행 안에 알 수 없는 답변이 있어도 입력 검사에서 먼저 중지합니다.

08실제 설문으로 넓힐 때 정할 것

실제 설문에서는 중복 제출의 의미부터 정해야 합니다. 나중 응답이 수정본인지, 동일 기기의 여러 사람인지, ID가 잘못 부여된 것인지에 따라 처리 정책이 달라집니다. 이 예제의 ‘중복 ID 전체 제외’를 모든 조사에 자동 적용하지 말고 조사 설계에 맞는 근거를 정하세요. ID가 같다는 사실만으로 실제 같은 사람이라고 증명할 수도 없습니다.

또한 이 코드는 한 문항의 선택형 답변만 다룹니다. 복수 선택, 조건에 따라 건너뛴 문항, 가중치, 자유 서술 분류, 표본 대표성이나 통계적 유의성은 포함하지 않습니다. 실제 연구 결론을 내리는 단계와 CSV의 구조를 확인하는 단계를 구별하고, 보고서에는 분석 대상 수와 제외 규칙을 함께 적는 것이 좋습니다.

자료를 모두 메모리에 읽으므로 대규모 설문의 성능 검증은 별도 작업입니다. 결과 쓰기 도중 디스크 오류가 나면 일부 출력이 남을 수 있으니 완료 문구와 세 파일을 확인합니다. 예제 검증에는 개인정보나 실제 설문 서비스 연결을 사용하지 않았습니다.

실행·검증 기록

2026-09-19 · Windows 11 · CPython 3.12.14 · 표준 라이브러리만 사용 · 임시 복사본에서 실행

  • 독립 테스트 14개 통과: 원시10행·고유ID9·중복제외2행·남은8명·빈응답2·유효6 확인
  • 선택지 비율 50.00%, 33.33%, 16.67%와 완료 비율 75.00% 확인
  • 빈 데이터·모든 답변 빈 칸·모든 ID 중복에서 분모 0 처리 확인
  • 중복 그룹의 빈 응답을 두 번 제외하지 않음 및 공백 정규화 확인
  • 입력 오류 거부, 기존 결과 보존, 실행 전후 원본 SHA-256 동일 확인
검증 범위의 한계
  • 실제 설문 서비스나 개인정보를 사용하지 않았습니다.
  • 표본 대표성·통계적 유의성·가중치·복수 선택은 검증하지 않습니다.
  • 실행 검증 환경은 Windows이며 대규모 데이터 성능은 측정하지 않았습니다.

사이트 전체의 작성·검증 원칙

직접 실행할 예제 파일

코드·입력 데이터·실행 안내가 포함되어 있습니다. 압축을 풀고 README.txt부터 읽어 주세요.

예제 ZIP 다운로드

직접 작성한 연습 자료 · 원본을 따로 보관한 뒤 실행하세요.

참고 출처

설명과 예제는 직접 작성했습니다. 관련 동작과 개념은 아래 공식 자료에서 확인할 수 있습니다.