소개
방문객 합계가 크다는 이유만으로 최적의 개관 시간대가 정해지지는 않습니다. 기록 하나에서 나온 높은 시간당 수치도 판단 근거로는 약합니다. 전시 시설의 명시적인 규칙을 적용해 비교할 기록이 충분한 시간대를 찾고, 공동 1위를 모두 남기며, 실제 그래프와 함께 해석의 한계를 전달합니다.
앞선 시간별 관측 실습 또는 복합 키, 결측값, 시간당 방문객 수, 기록률에 대한 동등한 이해와 Python·pandas·Matplotlib 기초를 전제로 합니다. 환경은 원본 합성 데이터와 완성된 observe.py가 준비된 독립 상태로 시작합니다. 이전 환경의 파일은 필요하지 않습니다. 소요 시간은 측정한 완료 시간이 아니라 작성자의 추정치입니다.
명시적인 관측 근거 규칙 적용하기
이 단계에서는 비교 가능 조건과 순위 매기기를 분리합니다. WebIDE Tab 아래쪽 터미널을 열거나 왼쪽 위 메뉴에서 Terminal → New Terminal을 선택하세요. 준비된 작업 폴더에서 제공된 관측 분석을 실행합니다. 두 인수는 입력 디렉터리와 출력 디렉터리입니다.
cd /home/labex/project/opening-hours
python3 observe.py data results
cat results/summary.csv
요약에는 모든 시간대가 남아 있습니다. morning은 합계 40이지만 시간당 수치는 10이고, afternoon과 evening은 각각 12입니다. short는 기록된 한 시간만으로 시간당 20입니다. sparse는 기록 세 시간이 있지만 기록률은 0.6입니다. quiet는 실제 0을 관측했고 unlogged의 시간당 수치는 알 수 없습니다.
시설의 연습 규칙은 공개되어 있습니다. 관측 시간이 3시간 이상이고 기록률도 0.75 이상일 때만 비교 대상이 됩니다. 경계와 같은 값도 포함합니다. 계획 네 시간 중 관측 세 시간이면 두 경계를 모두 충족합니다. 비교 가능한 시간대끼리 관측 한 시간당 방문객 수로 비교하세요. 비교 대상의 최댓값과 차이의 절댓값이 1e-9 이하인 시간대를 모두 남깁니다. 이 작은 허용치는 부동소수점 표현을 위한 것이며 업무상 의미 있는 차이가 아닙니다. 비교 가능한 시간대가 없으면 제안을 보류합니다. 이는 이 연습의 판단 규칙이지 보편적인 통계적 신뢰도 기준이 아닙니다.
각 시간대의 이유도 보존합니다. 우선순위는 개관 예정 시간이 0이면 no_open_hours, 그렇지 않고 관측 시간이 3 미만이면 too_few_observations, 그 외에 기록률이 0.75 미만이면 low_coverage, 나머지는 eligible입니다. 이렇게 휴관 시간대, 기록 없는 시간대, 기록 수는 충분하지만 기록률이 낮은 시간대를 구분합니다. 조건을 충족하지 못한 시간대를 비교표에서 삭제하지 마세요.
set_index('slot')은 안정적인 시간대 이름을 행 레이블로 만듭니다. 후보의 최대 시간당 수치를 빼고 .abs().le(1e-9)를 적용하면 지정한 허용치 안의 값이 모두 남습니다. sorted는 결정 결과의 시간대 목록에 안정적인 표시 순서를 부여합니다.
recommend.py를 만듭니다. 앞에서처럼 >는 파일을 덮어쓰고, 따옴표가 있는 히어 도큐먼트는 PY까지 그대로 저장합니다. sys.argv는 요약 파일 경로와 출력 폴더를 읽습니다. .ge()는 '이상'이며 마스크는 열 전체를 분류합니다. 선택된 시간당 수치가 없으면 None은 JSON의 null이 됩니다. JSON은 구조화된 텍스트 형식입니다. json.dumps가 시간대 식별자 목록을 보존하므로 동률 결과는 우연히 첫 행이었던 항목 하나가 아니라 목록입니다.
cat > recommend.py <<'PY'
import sys
import json
from pathlib import Path
import pandas as pd
source = Path(sys.argv[1])
output = Path(sys.argv[2])
summary = pd.read_csv(source).set_index('slot')
summary['eligible'] = summary['observed_hours'].ge(3) & summary['coverage'].ge(0.75)
summary['reason'] = 'eligible'
summary.loc[summary['coverage'].lt(0.75), 'reason'] = 'low_coverage'
summary.loc[summary['observed_hours'].lt(3), 'reason'] = 'too_few_observations'
summary.loc[summary['planned_hours'].eq(0), 'reason'] = 'no_open_hours'
candidates = summary.loc[summary['eligible']]
best = float(candidates['rate'].max()) if len(candidates) else None
winners = sorted(candidates.index[(candidates['rate'] - best).abs().le(1e-9)].tolist()) if best is not None else []
decision = {'status': 'compare' if winners else 'defer', 'slots': winners, 'rate': best}
output.mkdir(parents=True, exist_ok=True)
summary.to_csv(output / 'comparison.csv')
(output / 'decision.json').write_text(json.dumps(decision, indent=2) + '\n')
print(summary[['observed_hours', 'coverage', 'rate', 'eligible', 'reason']])
print(json.dumps(decision))
PY
python3 recommend.py results/summary.csv results
cat results/decision.json
{
"status": "compare",
"slots": [
"afternoon",
"evening"
],
"rate": 12.0
}
동률인 두 시간대가 모두 남습니다. short는 시간당 수치가 높아도 후보가 아니며, sparse는 기록률 부족으로 제외됩니다. quiet는 시간당 0이어도 비교 대상입니다. 방문객이 적다고 데이터가 무효인 것은 아닙니다. 비교 가능한 수치가 모두 0이면 해당 시간대 모두 0으로 공동 1위이며 비교할 수 있습니다. 모두 조건 미충족이면 올바른 결과는 status: defer, 빈 slots 목록, rate: null입니다.
자가 확인: Explorer에서 results/comparison.csv를 열어 모든 시간대의 이유를 찾으세요. 호출 규약은 python3 recommend.py SUMMARY_CSV OUTPUT_DIRECTORY입니다. 고정된 시간대 이름이나 고정 우승자에 의존하지 않고 다른 올바른 요약에서도 동작해야 합니다.
제안 옆에 관측 근거 표시하기
이 단계에서는 결측값을 0으로 보이게 하지 않으면서 합계와 시간당 수치를 함께 표시합니다. 같은 WebIDE 터미널의 /home/labex/project/opening-hours에서 계속하세요.
최소 그래프 형식은 세로 막대 패널 두 개, 가로축 눈금의 시간대 식별자, 세로축 레이블 Total visitors와 Visitors per recorded hour입니다. 조건 미충족 시간대도 포함해 모든 시간대의 관측 시간 수와 기록률을 보여 줍니다. 합계나 시간당 수치가 결측이면 숫자 막대를 그리지 않고 unknown을 표시합니다. 색상, 글꼴, 크기, 시간대 순서는 자유입니다. 입력이 같으면 현재 스크립트로 같은 픽셀을 재현하는 결정적 그래프여야 하며 PNG 메타데이터는 달라도 됩니다. 무작위 스타일이나 현재 시각을 이미지에 넣지 마세요.
반복문은 zip으로 축, 열 이름, 단위 레이블을 한 묶음씩 가져옵니다. summary.iterrows()는 시간대와 행을 순회하고 enumerate는 막대의 숫자 위치를 더합니다. f-string의 :.0%는 소수를 소수점 없는 백분율로 표시합니다. pd.notna는 기록률 형식을 정하기 전에 값이 존재하는지 확인합니다. annotate는 데이터 위치에 글자를 놓습니다. xytext=(0, 5)와 textcoords='offset points'는 그 위치에서 위로 5 표시 포인트만큼 옮깁니다. ha='center'는 가운데 정렬, fontsize=7은 작은 근거 레이블을 만듭니다. 이 표시 설정은 계산을 바꾸지 않습니다.
다음 블록을 recommend.py에 한 번 추가합니다. >>는 기존 코드를 유지합니다. subplots는 축 두 개를 만들고 range(len(summary))는 시간대마다 막대 위치를 부여합니다. 관측 시간 수와 정수 백분율 기록률에는 n=N; cov=P%를, 정의되지 않은 기록률에는 cov=n/a를 씁니다. 각 레이블은 해당 위치에 근거가 얼마나 있는지 나타냅니다. 조건 미충족 시간대의 실제 시간당 수치도 표시되지만 이유와 결정 결과가 있으므로 제안 대상과 구분할 수 있습니다. 누락된 시간당 수치는 누락된 상태로 남습니다.
cat >> recommend.py <<'PY'
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
x = list(range(len(summary)))
for ax, column, label in zip(axes, ['visitors', 'rate'], ['Total visitors', 'Visitors per recorded hour']):
bars = ax.bar(x, summary[column], color='#26705e')
ax.set_xticks(x, summary.index, rotation=40, ha='right')
ax.set_ylabel(label)
ax.set_ylim(bottom=0)
ax.grid(axis='y', alpha=0.2)
for n, (slot, row) in enumerate(summary.iterrows()):
coverage = f"{row['coverage']:.0%}" if pd.notna(row['coverage']) else 'n/a'
note = f"n={int(row['observed_hours'])}; cov={coverage}"
value = row[column]
if pd.isna(value):
note = 'unknown\n' + note
ax.annotate(note, (n, 0 if pd.isna(value) else value), xytext=(0, 5), textcoords='offset points', ha='center', fontsize=7)
fig.suptitle('Opening opportunities: totals and observed-hour rates')
fig.tight_layout(rect=(0, 0.04, 1, 0.93))
fig.savefig(output / 'comparison.png', dpi=150)
plt.close(fig)
choice = ', '.join(winners) if winners else 'none'
advice = f"Decision: {decision['status']}\nSlots: {choice}\n"
advice += 'Evidence: historical description\n'
advice += 'Effect of extending hours: not established\n'
(output / 'advice.txt').write_text(advice)
print(advice)
PY
python3 recommend.py results/summary.csv results
WebIDE Explorer에서 results/comparison.png를 여세요. 합계와 시간당 수치 패널을 함께 읽습니다. 아침의 합계가 많아도 시간당 방문객 수는 오후와 저녁이 더 높습니다. short에는 n=1, sparse에는 cov=60%가 보입니다. quiet는 실제 0이지만 unlogged와 closed의 시간당 수치는 알 수 없습니다. 알 수 없는 값을 높이 0의 막대로 바꾸지 마세요.
cat results/advice.txt
Decision: compare
Slots: afternoon, evening
Evidence: historical description
Effect of extending hours: not established
제안에는 네 가지 공개 필드를 씁니다. Decision은 compare 또는 defer, Slots는 쉼표로 구분한 안정적인 시간대 ID 또는 none, Evidence는 historical description, Effect of extending hours는 not established입니다. 필드와 시간대 순서는 자유이며 뒤에 설명을 추가해도 됩니다. 짧은 구조화 필드는 설득력 있는 문장에만 의존하지 않고 제안을 실제 결정과 맞추고 해석 범위를 명시합니다.
제안은 관측된 과거의 기회와 명시적인 규칙에 한정됩니다. 시간 변경이 같은 시간당 방문객 수를 일으킨다는 것을 보여 주지 않습니다. 기록이 부족한 시간대를 결정하기 전에 누락된 기록을 수집하는 것이 유용한 다음 행동입니다. advice.txt는 결과를 전달하지만 그 근거는 시간별 관계, 비교표, 결정 결과와 실제로 그린 값입니다.
두 스크립트, 원본 입력 CSV 두 개, hours.csv, summary.csv, comparison.csv, decision.json, 실제 PNG와 제안을 보존합니다. 파일 이름이 틀렸다면 오류를 읽고 ls로 찾아 올바른 경로로 실행하세요. 추가 대신 덮어썼다면 1단계 스크립트를 다시 만들고 이 블록을 한 번 추가합니다. 두 프로그램 모두 입력을 변경하지 않고 출력을 다시 계산합니다.
자가 확인: 그래프 값은 현재 요약에 기반하고, 모든 동률 후보가 있으며, 결과에는 한계 설명이 붙습니다. 챌린지에서는 다른 시설의 시간대 ID와 근거가 달라지지만 분석 규칙은 같습니다.
요약
비교 대상이 될 조건과 순위 매기기를 분리하고, 공동 1위를 모두 보존하며 제외된 시간대마다 이유를 남겼습니다. 두 실제 막대그래프로 방문객 합계와 기록된 한 시간당 방문객 수를 나란히 표시하고 관측 수와 기록률을 더했습니다. 알 수 없는 시간당 수치는 그대로 남았고, 제안에는 과거를 요약한 관측 데이터로 인과관계를 해석할 수 있는 범위를 명시했습니다.
스크립트는 다른 시설의 결과도 다시 계산할 수 있습니다. 올바른 분석은 제안을 보류하거나, 여러 동률 후보를 남기거나, 실제 방문객이 적은 비교 가능 시간대를 비교할 수 있습니다. 눈에 띄는 우승자 하나를 반드시 고를 필요는 없습니다.



