AI 개발 · 실무
바이브 코딩 실전 예제 - 장마다 하나씩 완성하는 12가지 프로그램
그래프 없이 보는 통계 - 설문 응답 요약
statistics 모듈(평균·중앙값·최빈값), 결측 응답, 텍스트 막대 그래프와 백분율 반올림 합 100 맞추기, 해석 문장 자동 생성의 한계
개발자KR · 원고 갱신
이 장에서 배우는 것
설문 결과를 확인할 때마다 그래프가 필요한 것은 아니다. 응답 수, 대표값, 항목별 비율을 짧은 문장과 텍스트 막대로 정리하면 터미널에서도 결과를 읽을 수 있다. 다만 숫자를 출력하는 일보다 먼저 정해야 할 것이 있다. 빈칸을 어떤 의미로 볼지, 백분율의 분모를 무엇으로 삼을지, 같은 빈도로 나온 값을 어떻게 보여 줄지다.
이 장에서는 작은 교육 모임의 설문 응답을 요약하는 프로그램을 만든다. 앞 장에서 명령을 나누는 방법을 살펴보았다면, 여기서는 입력을 정리하는 규칙과 통계의 의미를 맞추는 데 집중한다. 완성 프로그램은 예제 CSV 파일을 임시 폴더에 만들고, 다시 읽어서 검사한 뒤 결과를 출력하고 끝난다. 외부 패키지나 네트워크 연결은 필요하지 않다.
- statistics 모듈로 평균, 중앙값, 공동 최빈값을 구한다.
- 결측 응답과 잘못된 응답을 구분하고 문항별 유효 응답 수를 센다.
- 텍스트 막대와 백분율을 출력하고 표시 비율의 합을 100.0%로 맞춘다.
- 자동 해석 문장이 설명할 수 있는 사실과 설명할 수 없는 원인을 구분한다.
- AI가 작성한 코드를 경계값 검사와 변경 사항 비교로 확인한다.
문제 상황
작은 교육 모임을 마친 뒤 운영 담당자가 설문 열 건을 받았다. 질문은 두 개다. 만족도를 1점부터 5점까지 고르고, 다음 모임에서 원하는 진행 방식을 온라인·대면·혼합 중 하나로 고르게 했다. 어떤 사람은 만족도만 적었고, 어떤 사람은 진행 방식만 적었다. 담당자는 다음 회의에 붙일 짧은 요약을 원한다.
처음에는 빈칸을 모두 지우고 평균만 계산하려 했다. 그러나 행 전체를 지우면 한 문항의 빈칸 때문에 다른 문항의 정상 응답도 사라진다. 반대로 빈칸을 0점으로 바꾸면 원래 선택지에 없던 점수가 평균에 들어간다. 이 설문에서는 두 문항을 각각 집계해야 한다.
진행 방식도 작은 문제가 있다. 유효 응답 아홉 건에서 세 항목이 각각 세 표를 받았다. 각 비율을 소수 첫째 자리까지 반올림하면 33.3%가 세 개여서 합계가 99.9%가 된다. 보고서에서 합계를 100.0%로 표시하려면 남은 0.1%포인트를 배분하는 규칙이 필요하다.
| 대상 | 처리 규칙 | 확인할 결과 |
|---|---|---|
| 만족도 | 1~5의 정수만 허용하고 빈칸은 결측으로 센다. | 유효 8건, 결측 2건이다. |
| 진행 방식 | 정해진 세 항목만 허용하고 문항 자체의 유효 응답을 분모로 쓴다. | 유효 9건, 결측 1건이다. |
| 비율 | 0.1%포인트 단위로 배분하며 동률은 항목의 고정 순서로 푼다. | 33.4%, 33.3%, 33.3%다. |
| 실행 | 표준 라이브러리만 쓰고 입력과 출력 순서를 고정한다. | 실행할 때마다 같은 보고서를 만든다. |
이 요구사항을 정리한 뒤 AI에게 다음과 같이 요청한다. 구현을 맡기더라도 빈칸의 의미와 분모를 결정하는 일은 요청하는 사람이 먼저 해야 한다.
Python 3.12 이상에서 표준 라이브러리만 사용하는 main.py를 작성하라. tempfile.TemporaryDirectory 안에 UTF-8 CSV를 직접 쓰고 다시 읽어라. 열은 만족도와 진행방식이다. 만족도는 1~5 정수이며 공백만 있는 셀은 결측이다. 진행방식은 온라인, 대면, 혼합만 허용한다. 문항별로 결측을 따로 집계하고 잘못된 값은 행 번호를 포함한 ValueError로 알려라.
statistics로 평균·중앙값·공동 최빈값을 계산하라. 진행 방식은 고정 순서로 출력하고 유효 응답만 분모로 삼아라. 비율은 소수 첫째 자리까지 표시하되 합이 100.0%가 되도록 가장 큰 나머지부터 0.1%포인트씩 배분하라. 나머지가 같으면 온라인, 대면, 혼합 순서를 적용하라. 최장 20칸인 텍스트 막대, 응답 분모, 결측 수, 단순 사실 문장을 출력하라. 빈 입력과 잘못된 값에 대한 자체 검사도 포함하라.
결측을 정리한 뒤 대표값을 계산한다
결측 응답은 답이 없는 상태다. 이 프로그램에서는 앞뒤 공백을 제거한 결과가 빈 문자열이면 결측으로 본다. 숫자 0, 문자열 ‘모름’, 숫자로 읽을 수 없는 문자열은 결측이 아니다. 허용하지 않은 값이다. 실제 설문에 ‘모름’이라는 선택지가 있다면 별도 항목으로 정의해야 한다.
잘못된 값을 조용히 버리는 방식도 피한다. 예를 들어 6점이 들어왔는데 이를 결측으로 바꾸면 입력 오류가 보고서에서 사라진다. 여기서는 잘못된 값이 하나라도 있으면 보고서 생성을 중단한다. 작은 자동화 프로그램에서는 오류가 난 위치를 분명하게 알리는 편이 수정하기 쉽다.
정리한 점수에 statistics.mean을 적용하면 산술평균을 얻는다. statistics.median은 정렬했을 때 가운데에 놓이는 값을 구한다. 값의 개수가 짝수이면 가운데 두 값의 평균을 쓴다. 이 예제의 점수 여덟 개는 정렬하면 2, 3, 3, 4, 4, 4, 5, 5다. 평균은 3.75이고 중앙값은 4다.
만족도는 순서가 있는 응답이다. 평균을 계산하는 데에는 점수 사이의 간격을 같은 크기로 취급한다는 전제가 들어간다. 따라서 평균 하나만 보고 설문 전체를 설명하려 하지 않는다. 중앙값과 응답 수를 함께 보여 주고, 필요하면 나중에 점수별 분포를 추가한다.
최빈값은 가장 자주 나온 값이다. statistics.mode는 공동 최빈값이 있을 때 처음 만난 값을 돌려주므로, 모든 공동 최빈값을 보여 주려는 요구에는 statistics.multimode가 맞는다. multimode의 결과도 정렬해서 입력 행의 배치에 따라 표시 순서가 달라지지 않도록 한다. 모든 값이 한 번씩만 나왔다면 모든 값이 공동 최빈값으로 반환된다는 점도 알아 두어야 한다.
빈 목록에는 대표값을 계산하지 않는다. 유효 응답이 없을 때 0을 반환하면 ‘평균 0점’이라는 다른 의미가 생긴다. 완성 코드의 summarize 함수는 이 경우 None을 반환하고, 출력 부분이 ‘계산 불가’라는 문장으로 바꾼다. 계산 결과와 사용자에게 보여 줄 표현을 나누면 처리 규칙이 분명해진다.
모듈의 동작을 추가로 확인하려면 Python 공식 statistics 문서를 참고할 수 있다. 문서는 함수의 반환 규칙을 확인하는 근거로 사용하고, 이 설문의 결측 처리나 보고서 문장은 직접 정한다.
백분율의 합과 텍스트 막대의 기준을 정한다
표시 비율의 합을 맞추는 작업은 원래 응답 수를 바꾸는 일이 아니다. 소수 첫째 자리까지 쓰는 보고서에서 발생한 표시 오차를 배분하는 일이다. 계산에서는 100.0%를 정수 1000단위로 취급한다. 한 단위가 0.1%포인트이므로 부동소수점 연산 없이 배분할 수 있다.
각 항목에 대해 ‘응답 수 × 1000’을 전체 유효 응답 수로 나눈다. 몫은 먼저 확보한 단위 수이고 나머지는 아직 반영하지 못한 부분이다. 몫의 합이 1000보다 작으면 나머지가 큰 항목부터 한 단위씩 더한다. 이 방식을 최대 나머지 방식이라고 부른다.
세 항목이 각각 세 표를 받으면 몫은 모두 333이고 나머지도 같다. 먼저 999단위를 배정하고 남은 한 단위를 고정 순서의 첫 항목에 준다. 그래서 온라인은 33.4%, 나머지는 33.3%가 된다. 이 0.1%포인트 차이는 선호 차이가 아니다. 보고서에 보정 사실을 함께 적어야 하는 이유다.
모든 응답 수가 0이면 배분할 단위가 없다. 이 경우 함수는 항목 수만큼 0을 반환한다. 보고서도 비율을 ‘계산 불가’로 표시한다. 유효 응답이 없는 상황에서까지 합계 100.0%를 강제하면 응답하지 않은 사람의 선택을 만들어 내게 된다.
텍스트 막대는 응답 수를 기준으로 만든다. 이 장에서는 가장 많은 표를 받은 항목을 20칸으로 표시하고 다른 항목을 그에 비례시킨다. 따라서 막대 20칸은 100%를 뜻하지 않는다. 출력에도 그 기준을 적는다. 세 항목의 응답 수가 같으면 보정 비율이 달라도 막대 길이는 같다.
짧은 막대는 문자 칸의 제약 때문에 실제 차이를 모두 표현하지 못한다. 양수인 항목은 최소 한 칸을 주되 정확한 응답 수를 옆에 적는다. 터미널이나 글꼴에 따라 한글의 폭이 달라질 수 있으므로 표처럼 세로 경계를 맞추는 데 의존하지 않는다.
AI의 첫 결과를 검토할 때는 함수 이름이 등장했는지만 확인하지 않는다. 다음 표처럼 실패할 입력과 확인 방법을 짝지어 둔다. 이 장에서 고치는 초안은 빈칸을 0으로 대체하고 각 비율을 독립적으로 반올림하는 흔한 형태다.
| 문제 | 발견 방법 | 고친 방향 |
|---|---|---|
| 빈 점수를 0점으로 바꾸어 평균이 낮아졌다. | 5점과 빈칸 두 행으로 평균을 확인했다. | 빈칸을 점수 목록에서 빼고 결측 수를 따로 셌다. |
| 각 비율에 round를 적용해 합계가 99.9%가 되었다. | 동일한 응답 수 세 개를 넣었다. | 정수 1000단위로 몫과 나머지를 계산했다. |
| mode 하나로 공동 최빈값을 숨겼다. | 2, 2, 4, 4를 넣었다. | multimode 결과를 정렬해 모두 표시했다. |
| 인코딩 생략으로 실행 환경에 따라 한글을 읽지 못할 여지가 있었다. | 파일을 여는 부분과 한글 왕복 읽기를 확인했다. | 읽기와 쓰기에 UTF-8을 명시했다. |
검사와 수정으로 해석의 범위를 좁힌다
수정 요청은 ‘통계를 더 정확하게 해 달라’보다 구체적이어야 한다. 어떤 입력에서 무엇이 나와야 하는지 적어야 AI의 수정과 우리의 검사가 같은 기준을 사용한다.
빈 점수는 0점으로 바꾸지 말고 결측으로 세어라. 점수 5와 빈칸을 넣으면 평균은 5이고 결측은 1이어야 한다. 2, 2, 4, 4의 공동 최빈값은 2와 4를 모두 표시하라. 응답 수 3, 3, 3은 표시 단위 334, 333, 333으로 배분하고 합계 1000을 검사하라. 유효 응답이 없으면 대표값과 비율을 계산할 수 없다고 표시하라. 수정 후 변경 비교에서 집계·입력 검증·출력 문장 이외의 불필요한 변경이 없는지도 확인하겠다.
자체 검사는 정상 예제만 다시 계산하지 않는다. 빈 목록, 공백뿐인 셀, 점수 범위 밖의 값, 정수가 아닌 점수, 정의하지 않은 진행 방식도 넣는다. 합계가 맞는다는 사실만으로 배분이 올바른 것은 아니므로, 동일한 세 항목의 배분 결과와 0표 항목이 있는 결과도 확인한다.
코드를 실행한 뒤에는 예상 보고서와 실제 출력을 비교한다. 변경 비교에서는 빈칸을 처리하는 분기가 다른 문항까지 건너뛰지 않는지, 정렬 기준이 바뀌지 않았는지, 임시 폴더 밖에 파일을 쓰지 않는지를 읽는다. 검사를 통과했다는 한 줄은 이런 검토를 대신하지 않는다. 아래 프로그램은 자체 검사를 통과한 경우에만 보고서를 출력하도록 구성한다.
해석 문장은 확인 가능한 사실만 만든다. ‘응답한 사람 중 최다 선택은 세 항목 공동이다’는 집계로 확인할 수 있다. 반면 ‘온라인 방식이 학습 효과를 높인다’는 문장은 이 설문으로 확인할 수 없다. 선호와 효과는 다른 질문이며, 응답하지 않은 사람의 생각도 알 수 없기 때문이다.
특히 보정된 33.4%를 근거로 온라인을 단독 1위로 판단해서는 안 된다. 순위와 공동 여부는 원래 응답 수로 판정한다. 완성 코드의 자동 문장은 가장 큰 응답 수를 찾고, 그 수와 같은 항목을 모두 나열한다. 백분율은 표시용으로만 사용한다.
완성 코드
다음 내용을 main.py로 저장한다. 검사 함수의 assert는 일반 실행에서 작동한다. 입력 검증은 assert에 맡기지 않고 ValueError를 직접 발생시키므로 실행 옵션과 관계없이 적용된다.
import csv
import statistics
import tempfile
from collections import Counter
from pathlib import Path
OPTIONS = ("온라인", "대면", "혼합")
FIELDS = ("만족도", "진행방식")
ROWS = (
("5", "온라인"),
("4", "대면"),
("4", "혼합"),
("3", "온라인"),
("5", "대면"),
("", "혼합"),
("2", "온라인"),
("4", "대면"),
(" ", "혼합"),
("3", ""),
)
def parse_score(raw, line):
text = raw.strip()
if not text:
return None
if text not in ("1", "2", "3", "4", "5"):
raise ValueError(f"{line}행: 만족도는 1~5 정수여야 한다.")
return int(text)
def collect(rows):
scores = []
counts = Counter({name: 0 for name in OPTIONS})
missing_score = 0
missing_choice = 0
for line, (raw_score, raw_choice) in enumerate(rows, start=2):
score = parse_score(raw_score, line)
if score is None:
missing_score += 1
else:
scores.append(score)
choice = raw_choice.strip()
if not choice:
missing_choice += 1
elif choice in OPTIONS:
counts[choice] += 1
else:
raise ValueError(f"{line}행: 알 수 없는 진행방식 {choice!r}")
return scores, counts, missing_score, missing_choice
def read_rows(path):
with path.open("r", encoding="utf-8", newline="") as file:
reader = csv.reader(file)
header = next(reader, None)
if header != list(FIELDS):
raise ValueError("CSV 머리글이 예상한 두 열과 다르다.")
rows = []
for line, row in enumerate(reader, start=2):
if len(row) != 2:
raise ValueError(f"{line}행: 열이 두 개여야 한다.")
rows.append(tuple(row))
return rows
def summarize(scores):
if not scores:
return None
return (
statistics.mean(scores),
statistics.median(scores),
sorted(statistics.multimode(scores)),
)
def percentage_units(counts):
if any(count < 0 for count in counts):
raise ValueError("응답 수는 음수일 수 없다.")
total = sum(counts)
if total == 0:
return [0] * len(counts)
parts = [divmod(count * 1000, total) for count in counts]
units = [quotient for quotient, remainder in parts]
remaining = 1000 - sum(units)
order = sorted(
range(len(counts)),
key=lambda index: (-parts[index][1], index),
)
for index in order[:remaining]:
units[index] += 1
return units
def format_percent(units):
return f"{units // 10}.{units % 10}%"
def make_bar(count, maximum):
if count == 0:
return ""
width = max(1, count * 20 // maximum)
return "#" * width
def expect_value_error(action):
try:
action()
except ValueError:
return
raise AssertionError("ValueError가 발생해야 한다.")
def run_checks():
scores, counts, ms, mc = collect(ROWS)
assert summarize(scores) == (3.75, 4.0, [4])
assert (ms, mc) == (2, 1)
assert [counts[name] for name in OPTIONS] == [3, 3, 3]
scores, _, ms, _ = collect((("5", "온라인"), (" ", "")))
assert summarize(scores) == (5, 5, [5])
assert ms == 1
assert summarize([]) is None
assert summarize([2, 2, 4, 4])[2] == [2, 4]
assert parse_score(" 1 ", 2) == 1
empty = collect(())
assert empty[0] == []
assert empty[2:] == (0, 0)
assert percentage_units([3, 3, 3]) == [334, 333, 333]
assert percentage_units([0, 1, 1]) == [0, 500, 500]
assert percentage_units([0, 0, 0]) == [0, 0, 0]
for a in range(5):
for b in range(5):
for c in range(5):
result = percentage_units([a, b, c])
assert sum(result) == (1000 if a + b + c else 0)
for invalid in ("0", "6", "3.5", "모름"):
expect_value_error(lambda value=invalid: parse_score(value, 2))
expect_value_error(lambda: collect((("3", "기타"),)))
assert make_bar(0, 0) == ""
assert make_bar(3, 3) == "#" * 20
def print_report(rows):
scores, counts, missing_score, missing_choice = collect(rows)
values = [counts[name] for name in OPTIONS]
total = sum(values)
units = percentage_units(values)
maximum = max(values)
print("설문 응답 요약")
print(f"전체 응답: {len(rows)}건")
print(f"만족도: 유효 {len(scores)}건, 결측 {missing_score}건")
summary = summarize(scores)
if summary is None:
print("평균·중앙값·최빈값: 계산 불가")
else:
mean, median, modes = summary
print(f"평균 {mean:.2f}점 / 중앙값 {median:.2f}점")
print("최빈값: " + ", ".join(f"{value}점" for value in modes))
print(f"진행 방식: 유효 {total}건, 결측 {missing_choice}건")
print("비율 분모: 진행 방식 유효 응답")
print("막대 기준: 최다 응답 20칸, 응답 수에 비례")
for name, count, unit in zip(OPTIONS, values, units):
ratio = format_percent(unit) if total else "계산 불가"
bar = make_bar(count, maximum)
print(f"{name}: {count}건 | {ratio} | {bar}")
if total:
print(f"표시 비율 합계: {format_percent(sum(units))}")
print("비율은 합계 100.0%가 되도록 0.1%포인트 단위로 보정했다.")
leaders = [name for name in OPTIONS if counts[name] == maximum]
label = "공동 최다 선택" if len(leaders) > 1 else "최다 선택"
print(f"관찰: {label}은 {', '.join(leaders)}이며 응답 수는 {maximum}건이다.")
else:
print("관찰: 진행 방식의 유효 응답이 없어 비교할 수 없다.")
print("해석 범위: 이 응답의 분포만 설명하며 원인과 전체 집단은 추정하지 않는다.")
def main():
run_checks()
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "survey.csv"
with path.open("w", encoding="utf-8", newline="") as file:
writer = csv.writer(file)
writer.writerow(FIELDS)
writer.writerows(ROWS)
rows = read_rows(path)
assert rows == list(ROWS)
print("자체 검사: 통과")
print_report(rows)
if __name__ == "__main__":
main()
줄별 해설
처음의 가져오기 문장은 파일 읽기, 통계 계산, 임시 폴더 관리에 필요한 도구를 준비한다. OPTIONS는 허용 항목인 동시에 출력 순서와 비율 동률 처리 순서다. 한 곳에서 순서를 정하므로 파일에 먼저 등장한 항목이 우연히 유리해지는 일을 막는다. ROWS에는 재현 가능한 예제 열 건을 고정했다.
parse_score는 입력 문자열의 앞뒤 공백부터 제거한다. 빈 문자열이면 None을 반환한다. 나머지는 허용 문자열 다섯 개와 직접 비교한다. 이 규칙은 ‘3.0’이나 ‘+3’도 허용하지 않는다. 값이 숫자처럼 보이는지보다 설문 파일의 표현 규칙에 맞는지를 검사하는 선택이다.
collect의 enumerate는 머리글 다음을 2행으로 센다. 점수와 진행 방식의 처리를 각각 수행하므로 점수 결측이 진행 방식 집계를 막지 않는다. Counter에는 처음부터 모든 항목을 0으로 넣어 둔다. 선택되지 않은 항목도 보고서에 남는다.
read_rows는 머리글의 이름과 순서, 각 행의 열 수를 확인한다. csv.reader를 쓰므로 쉼표나 인용 부호를 임의로 분리하지 않는다. 여기서 오류 메시지의 행 번호는 머리글을 포함한 CSV 레코드 순서다. 따옴표 안에 줄바꿈이 들어간 외부 파일에서는 물리적인 텍스트 줄 번호와 다를 수 있다.
summarize는 비어 있는 목록을 먼저 처리한다. 이후에만 평균, 중앙값, 공동 최빈값을 구한다. 결과는 계산값 그대로 반환하고 소수 자릿수는 출력할 때 정한다. 이렇게 해야 표시를 위해 반올림한 값을 다른 계산에 다시 쓰지 않는다.
percentage_units의 divmod는 몫과 나머지를 한 번에 구한다. 정렬 키의 첫 값은 나머지의 음수이므로 큰 나머지가 앞에 온다. 두 번째 값은 항목 위치여서 나머지가 같을 때 고정 순서가 유지된다. remaining개의 항목에 한 단위씩 더하면 유효 응답이 있는 경우 합계가 1000이 된다.
format_percent는 정수 단위를 문자열로 바꾼다. 334를 10으로 나눈 몫 33과 나머지 4를 이어 33.4%로 만든다. make_bar는 0표를 먼저 처리해서 0으로 나누는 일을 피한다. 양수 표가 있다면 최댓값도 양수이므로 그다음 계산을 진행할 수 있다.
run_checks는 예제의 대표값뿐 아니라 작은 응답 수 조합 125개에서 배분 합계를 확인한다. expect_value_error는 오류가 나야 하는 입력이 통과하면 AssertionError를 일으킨다. 다만 이 검사들이 모든 파일 형태와 설문 규칙을 검증하는 것은 아니다. 입력 형식을 확장하면 그에 맞는 사례도 추가해야 한다.
print_report는 원래 응답 수로 공동 최다 선택을 찾는다. 표시 비율의 미세한 차이는 판단에 사용하지 않는다. 마지막 main은 검사, 파일 생성, 파일 읽기, 보고서 출력 순서를 묶는다. 임시 폴더 경로는 출력하지 않아 실행마다 달라지는 문자열을 결과에서 제외한다. with 블록을 벗어나면 예제 파일도 함께 정리된다.
실행 결과
macOS 또는 Linux 터미널에서 main.py를 저장한 폴더로 이동한 뒤 다음 명령을 실행한다. 경고를 오류로 취급하는 컴파일 검사도 먼저 수행할 수 있다. 아래 명령은 Python 3.12 이상을 기준으로 한다.
python3 -W error -m py_compile main.py
python3 main.py
컴파일 검사가 성공하면 별도 메시지를 출력하지 않는다. 프로그램을 일반 실행했을 때의 예상 출력은 다음과 같다.
자체 검사: 통과
설문 응답 요약
전체 응답: 10건
만족도: 유효 8건, 결측 2건
평균 3.75점 / 중앙값 4.00점
최빈값: 4점
진행 방식: 유효 9건, 결측 1건
비율 분모: 진행 방식 유효 응답
막대 기준: 최다 응답 20칸, 응답 수에 비례
온라인: 3건 | 33.4% | ####################
대면: 3건 | 33.3% | ####################
혼합: 3건 | 33.3% | ####################
표시 비율 합계: 100.0%
비율은 합계 100.0%가 되도록 0.1%포인트 단위로 보정했다.
관찰: 공동 최다 선택은 온라인, 대면, 혼합이며 응답 수는 3건이다.
해석 범위: 이 응답의 분포만 설명하며 원인과 전체 집단은 추정하지 않는다.
검사 통과 문장, 문항별 유효 응답 수, 비율 합계, 공동 최다 선택 문장을 순서대로 대조한다. 온라인의 비율이 조금 높지만 막대와 응답 수가 같은 것도 확인한다. 이는 잘못된 불일치가 아니라 표시 보정과 실제 응답 수를 구분한 결과다.
실무에서 자주 틀리는 것
빈칸을 숫자 0으로 바꾼다
다음 코드는 실행되지만 빈칸까지 점수로 취급한다. 만족도 척도에 없는 0이 평균을 낮춘다.
import statistics
raw_scores = ["5", ""]
scores = [int(value or 0) for value in raw_scores]
assert statistics.mean(scores) == 2.5
빈칸을 제외하고 결측 수를 따로 구해야 한다. 아래 예시는 원리를 분리한 것으로, 실제 프로그램에서는 범위 검증까지 하는 parse_score를 사용한다.
import statistics
raw_scores = ["5", ""]
scores = [int(value.strip()) for value in raw_scores if value.strip()]
missing = sum(not value.strip() for value in raw_scores)
assert statistics.mean(scores) == 5
assert missing == 1
전체 행 수를 모든 문항의 분모로 쓴다
열 건 중 진행 방식에 아홉 명이 답했는데 전체 열 건을 분모로 쓰면 각 항목은 30%가 된다. 전체 응답자 중 선택 비중을 구하려는 경우에는 가능한 계산이지만, 유효 응답 안에서의 분포라는 현재 요구와 다르다.
counts = [3, 3, 3]
all_rows = 10
ratios = [count / all_rows * 100 for count in counts]
assert sum(ratios) == 90
문항별 유효 응답 수를 분모로 구하고 결측은 별도로 알린다. 비율을 비교하는 보고서끼리도 분모 정의가 같은지 확인해야 한다.
counts = [3, 3, 3]
valid = sum(counts)
ratios = [count / valid * 100 for count in counts] if valid else None
assert ratios is not None
assert len(ratios) == 3
합계만 맞추려고 마지막 항목에 차이를 몰아준다
각 항목을 반올림한 뒤 마지막 항목에 남은 차이를 주면, 나머지가 가장 큰 항목과 관계없이 마지막 항목이 보정된다. 다음 입력에서는 앞의 두 항목이 마지막 항목보다 큰 나머지를 가진다.
counts = [1, 1, 4]
units = [round(count * 1000 / sum(counts)) for count in counts]
units[-1] += 1000 - sum(units)
assert units == [167, 167, 666]
몫부터 배정하고 나머지 순서로 추가 배정하면 이 예제는 167, 166, 667이 된다. 아래 코드는 같은 계산을 독립적으로 확인한다.
counts = [1, 1, 4]
total = sum(counts)
parts = [divmod(count * 1000, total) for count in counts]
units = [part[0] for part in parts]
order = sorted(range(len(counts)), key=lambda i: (-parts[i][1], i))
for index in order[:1000 - sum(units)]:
units[index] += 1
assert units == [167, 166, 667]
보정된 표시 비율로 단독 1위를 고른다
표시 비율의 최댓값을 찾으면 온라인 하나가 선택된다. 그러나 실제 응답은 모두 세 건이다.
options = ("온라인", "대면", "혼합")
units = [334, 333, 333]
leader = options[units.index(max(units))]
assert leader == "온라인"
최다 선택은 원래 응답 수로 구한다. 자동 문장의 판단 근거와 사용자에게 보여 줄 숫자의 용도를 구분해야 한다.
options = ("온라인", "대면", "혼합")
counts = [3, 3, 3]
leaders = [name for name, count in zip(options, counts) if count == max(counts)]
assert leaders == ["온라인", "대면", "혼합"]
한눈에 보기
| 항목 | 이 장의 선택 | 검토할 질문 |
|---|---|---|
| 결측 | 문항별로 제외하고 건수를 표시한다. | 빈칸 때문에 다른 문항의 답까지 버리지 않았는가. |
| 대표값 | 평균·중앙값·공동 최빈값을 함께 구한다. | 유효 응답이 없을 때 계산을 건너뛰는가. |
| 백분율 | 유효 응답을 분모로 삼고 정수 단위로 보정한다. | 합계와 동률 처리 순서가 정해졌는가. |
| 막대 | 최다 응답을 20칸으로 표시한다. | 독자가 20칸을 100%로 오해하지 않는가. |
| 해석 문장 | 응답 수에서 확인한 사실만 말한다. | 원인이나 전체 집단으로 범위를 넓히지 않았는가. |
작은 보고서의 신뢰성은 복잡한 계산보다 규칙의 일관성에서 나온다. 결측의 의미, 문항별 분모, 표시 보정, 자동 문장의 판단 근거를 코드와 출력에 함께 남기면 다음 사람이 결과를 검토하기 쉬워진다.
연습 문제
다음 요청문으로 AI에게 확장을 맡기되, 요구한 기능만 바뀌었는지 변경 비교를 읽고 실행 결과를 확인한다. 각 문제의 입력과 예상 결과를 수정 전에 먼저 적어 둔다.
만족도의 점수별 응답 수를 추가한다.
현재 프로그램에 1점부터 5점까지의 빈도를 고정 순서로 출력하는 기능을 추가하라. 0건인 점수도 표시하고 빈도 합이 만족도 유효 응답 수와 같은지 검사하라. 기존 대표값과 진행 방식 출력 규칙은 유지하라.
모든 문항이 결측인 설문을 검사한다.
점수와 진행 방식이 모두 빈칸인 행 두 개를 넣어 보고서 함수를 검사하라. 평균 0점이나 합계 100.0%를 만들어 내지 말고 계산 불가를 표시하라. 결측 수가 문항별로 2건인지도 확인하라.
응답 수가 매우 작은 항목의 막대를 검토한다.
응답 수가 1, 1, 98일 때 현재 막대 길이를 계산하라. 최소 한 칸 규칙이 비율을 정확하게 재현하지 못하는 이유를 설명하고, 보고서에 덧붙일 안내 문장과 검사를 제안하라.
자동 해석 문장을 검토한다.
‘온라인이 33.4%로 가장 효과적인 방식이다’라는 문장이 현재 데이터에서 정당한지 검토하라. 표시 비율 보정, 공동 응답 수, 선호와 효과의 차이를 구분하고, 집계로 확인할 수 있는 문장으로 다시 작성하라.
정답과 해설
Counter(scores)를 만든 뒤 1부터 5까지 순회하면 된다. 예제의 빈도는 차례로 0, 1, 2, 3, 2건이고 합계는 8건이다. 점수 목록에 결측이 들어 있지 않으므로 빈도 합계와 유효 응답 수가 일치한다. 0건 항목까지 순회하는 이유는 실행마다 보고서의 항목 구성을 유지하기 위해서다.
전체 응답은 2건이고 두 문항 모두 유효 0건, 결측 2건이다. summarize는 None을 반환한다. 진행 방식 세 항목의 응답 수는 모두 0이고 비율은 계산 불가로 표시된다. 표시 비율 합계와 보정 안내는 출력하지 않는다. 같은 방식으로 ROWS를 빈 튜플로 바꾸면 전체 응답과 결측 수가 모두 0인 경우도 확인할 수 있다.
최댓값은 98이므로 앞의 두 항목은 정수 나눗셈 결과가 0이지만 최소 길이 규칙으로 한 칸이 된다. 마지막 항목은 20칸이다. 따라서 막대 길이는 1, 1, 20이며 실제 비율 1%, 1%, 98%와 길이 비율이 같지 않다. ‘양수 응답은 최소 한 칸이며 정확한 값은 건수와 비율을 따른다’라는 안내를 추가할 수 있다. make_bar(1, 98)이 한 칸인지 검사하면 해당 규칙을 확인할 수 있다.
33.4%는 표시 합계를 맞추는 과정에서 생긴 값이며 세 항목은 각각 세 표로 공동이다. 또한 설문은 원하는 진행 방식을 물었으므로 효과를 측정하지 않았다. ‘진행 방식에 답한 9명 중 온라인·대면·혼합을 각각 3명이 선택했다’라고 쓰면 집계 범위 안에 머문다. 응답하지 않은 사람이나 다른 모임까지 같은 경향이라고 추정할 근거는 추가로 필요하다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.