공개자료를 모을 때 출처·기준일·단위를 함께 기록하기
자료명과 URL만 모으는 대신 기준일·게시일·확인일·단위·이용조건을 한 행에 기록합니다. 코드 없이 쓰는 CSV 템플릿과 체크리스트를 제공합니다.
합성 응답 10행에서 중복 ID와 빈 답변을 구분합니다. 유효 응답의 분모를 명시하고 선택지별 개수와 비율, 제외 사유를 새 파일에 남깁니다.
이런 분께설문을 CSV로 내보낸 뒤 응답 수와 백분율을 직접 확인하고 싶은 입문자
설문 파일의 행 수를 그대로 응답자 수라고 쓰면 중복 제출이 포함될 수 있습니다. 빈 답변을 분모에 넣는지에 따라서도 같은 선택지의 비율이 달라집니다. 이 예제에서는 ‘다시 이용할 의향이 있나요?’라는 한 문항을 사용해 집계 전에 세울 규칙을 연습합니다. 실행보다 먼저 원본에서 중복과 빈 답변을 찾아보세요.
python example.py코드는 example.py가 있는 위치를 기준으로 responses.csv를 찾습니다. 출력은 별도 outputs에 기록하고 입력은 바꾸지 않습니다. 다른 작업 폴더에서 스크립트 경로로 실행하더라도 같은 예제 입력을 읽습니다.
R005는 ‘예’와 ‘아니오’를 각각 제출했습니다. 이 예제는 처음 값이나 마지막 값을 임의로 선택하지 않고, 두 번 이상 나온 ID의 모든 행을 집계에서 제외합니다. 어느 응답이 유효한지 판단할 정보가 없기 때문입니다. 이 정책은 코드와 요약 파일의 duplicate_policy에 명시되어 있습니다.
| 수치 | 계산 | 예제 결과 |
|---|---|---|
| 원시 데이터 행 | 헤더를 제외한 읽은 행 | 10행 |
| 고유 ID | 같은 ID를 한 번만 셈 | 9개 |
| 중복으로 제외한 행 | R005의 모든 행 | 2행 |
| 남은 고유 응답자 | 10 − 2 | 8명 |
| 유효 응답 | 남은 8명 − 빈 답변 2명 | 6명 |
응답 완료 비율은 남은 고유 응답자 8명 중 유효 답변 6명의 비율인 75.00%입니다. 각 선택지의 비율은 유효 답변 6명을 분모로 삼습니다. 고유 ID 9개를 그대로 분모로 쓰지 않는 이유는 R005의 응답 전체를 분석에서 제외했기 때문입니다.
read_rows는 열과 ID, 선택지를 검사합니다. summarize는 ID 빈도를 먼저 센 뒤 행마다 duplicate_id, blank_answer, valid 중 하나의 상태를 붙입니다. 중복 행을 먼저 판정하기 때문에 같은 행을 중복 제외와 빈 응답 제외에 이중으로 더하지 않습니다.
"""설문 합성 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와 답변의 앞뒤 공백은 제거합니다. 알 수 없는 선택지를 조용히 버리지 않고 중지하며, 모든 입력 검사가 끝난 다음에만 출력 폴더를 만듭니다.
| 선택지 | 개수 | 분모 | 백분율 |
|---|---|---|---|
| 예 | 3 | 6 | 50.00% |
| 아니오 | 2 | 6 | 33.33% |
| 잘 모르겠음 | 1 | 6 | 16.67% |
‘예’는 R001, R003, R007의 세 응답입니다. R005의 ‘예’는 제외되었으므로 네 개가 아닙니다. ‘아니오’는 R002와 R008 두 응답이고 ‘잘 모르겠음’은 R006 한 응답입니다. 세 선택지의 개수를 더한 6이 valid_answer_rows와 같아야 합니다.
choices.csv는 answer, count, denominator, percent 네 열을 제공합니다. percent에는 % 기호 없이 50.00 같은 숫자 문자열을 기록합니다. 이를 표에 옮길 때는 이미 100을 곱한 백분율이라는 점을 확인하세요. 스프레드시트의 백분율 형식을 그대로 적용해 5000%로 만들지 않도록 주의합니다.
| 파일 | 확인할 내용 |
|---|---|
| summary.json | 원시 행·고유 ID·제외 행·두 분모·비율 |
| choices.csv | 선택지별 개수와 유효 응답 분모 |
| audit_rows.csv | 데이터 행 순서·ID·답변·판정 상태 |
data_row는 헤더를 제외하고 읽힌 데이터의 순서입니다. 물리적인 빈 줄은 CSV 읽기에서 건너뛰므로 텍스트 편집기의 줄 번호와 항상 같지는 않습니다. 무응답을 표현할 때는 완전히 빈 줄이 아니라 R004,처럼 ID가 있고 answer만 빈 행을 사용합니다.
연습을 반복할 때는 첫 결과를 다른 이름으로 옮겨 보관하거나 ZIP을 새 폴더에 풉니다. R004의 빈 답변을 ‘예’로 바꾼 사본을 실행해 보세요. 중복 제외 후 응답자 수는 여전히 8명이고 유효 응답은 7명입니다. ‘예’는 4/7 = 57.14%, 응답 완료 비율은 7/8 = 87.50%가 되어야 합니다.
모든 고유 응답자의 answer를 빈 칸으로 만드는 경우도 생각해 볼 수 있습니다. 남은 응답자가 있지만 유효 답변이 0이면 응답 완료 비율은 0.00%입니다. 반면 선택지 비율의 분모는 0이므로 계산하지 않습니다. JSON에는 null, CSV에는 빈 칸을 기록해 0%와 구별합니다.
| 중지 원인 | 확인 방법 | 다음 행동 |
|---|---|---|
| 잘못된 열 이름 또는 순서 | 첫 줄이 respondent_id,answer인지 확인 | 내보낸 사본의 열을 규칙에 맞춥니다. |
| 빈 ID 또는 형식 불일치 | ID가 R과 숫자 세 자리인지 확인 | 합성 예제 규칙에 맞게 ID를 수정합니다. |
| 알 수 없는 답변 | 예·아니오·잘 모르겠음 외 값 확인 | 오타인지 새 선택지인지 원자료에서 확인합니다. |
| 열 수 불일치 | 쉼표 추가 또는 누락 확인 | CSV 구조를 복구한 사본으로 실행합니다. |
| outputs가 이미 있음 | 이전 결과 폴더 존재 확인 | 결과를 다른 이름으로 보관한 뒤 재실행합니다. |
입력 오류가 나면 출력 폴더를 만들기 전에 중지합니다. 오류를 피하려고 해당 행부터 삭제하면 제외한 근거가 사라집니다. 원본을 남겨 두고 사본의 수정 사항과 규칙을 기록한 뒤 다시 실행하세요. 중복 행 안에 알 수 없는 답변이 있어도 입력 검사에서 먼저 중지합니다.
실제 설문에서는 중복 제출의 의미부터 정해야 합니다. 나중 응답이 수정본인지, 동일 기기의 여러 사람인지, ID가 잘못 부여된 것인지에 따라 처리 정책이 달라집니다. 이 예제의 ‘중복 ID 전체 제외’를 모든 조사에 자동 적용하지 말고 조사 설계에 맞는 근거를 정하세요. ID가 같다는 사실만으로 실제 같은 사람이라고 증명할 수도 없습니다.
또한 이 코드는 한 문항의 선택형 답변만 다룹니다. 복수 선택, 조건에 따라 건너뛴 문항, 가중치, 자유 서술 분류, 표본 대표성이나 통계적 유의성은 포함하지 않습니다. 실제 연구 결론을 내리는 단계와 CSV의 구조를 확인하는 단계를 구별하고, 보고서에는 분석 대상 수와 제외 규칙을 함께 적는 것이 좋습니다.
자료를 모두 메모리에 읽으므로 대규모 설문의 성능 검증은 별도 작업입니다. 결과 쓰기 도중 디스크 오류가 나면 일부 출력이 남을 수 있으니 완료 문구와 세 파일을 확인합니다. 예제 검증에는 개인정보나 실제 설문 서비스 연결을 사용하지 않았습니다.
2026-09-19 · Windows 11 · CPython 3.12.14 · 표준 라이브러리만 사용 · 임시 복사본에서 실행
코드·입력 데이터·실행 안내가 포함되어 있습니다. 압축을 풀고 README.txt부터 읽어 주세요.
예제 ZIP 다운로드직접 작성한 연습 자료 · 원본을 따로 보관한 뒤 실행하세요.
설명과 예제는 직접 작성했습니다. 관련 동작과 개념은 아래 공식 자료에서 확인할 수 있습니다.