はじめに
来場者の合計が大きいだけでは、最適な開館時間帯は決まりません。1回の記録から得た高い時間当たりの率も、判断の根拠としては弱いものです。展示施設の明示的なルールを適用して、比較に十分な記録がある時間帯を見つけ、同率首位をすべて残し、実際のグラフとともに解釈の限界を伝えます。
このラボは、前の時間単位の観測ラボ、または複合キー、欠測、時間当たりの率、カバー率についての同等の知識に加え、Python、pandas、Matplotlib の基礎を前提とします。環境は独立して開始し、元の合成データと完全な observe.py が用意されています。前の環境のファイルは不要です。所要時間は作者の見積もりで、実測した完了時間ではありません。
明示的な根拠のルールを適用する
このステップでは、比較の適格条件と順位付けを分けます。WebIDE Tab の下部ターミナルを開くか、左上のメニューで Terminal → New Terminal を選択します。用意された作業領域から、提供されている観測分析を実行してください。2つの引数は入力ディレクトリと出力ディレクトリです。
cd /home/labex/project/opening-hours
python3 observe.py data results
cat results/summary.csv
集計には全時間帯が残っています。morning の合計は40ですが率は10、afternoon と evening の率はそれぞれ12です。short は1時間だけの記録から率20となっています。sparse は3時間の記録がありますが、カバー率は0.6です。quiet は実際にゼロを観測していますが、unlogged の率は不明です。
展示施設の演習ルールは公開されています。観測時間数が3以上であり、かつカバー率が0.75以上の場合だけ、その時間帯を比較対象にできます。境界と等しい値も適格です。4計画時間に3観測があれば、両方の境界を満たします。適格な時間帯を、観測1時間当たりの来場者数で比較してください。適格な最大の率との差の絶対値が 1e-9 以下の時間帯をすべて残します。この小さな許容差は浮動小数点表現に対応するもので、業務上意味のある差ではありません。適格な時間帯がなければ提案を保留します。これは演習の判断ルールであり、普遍的な統計的信頼度のしきい値ではありません。
各時間帯の理由も残します。優先順序は、計画時間がゼロなら 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
}
2つの同率時間帯が両方とも残ります。short は率が高くても候補ではなく、sparse はカバー率不足で除外されます。quiet は率ゼロでも適格です。来場者が少ないことはデータの無効を意味しません。適格な率がすべてゼロなら、適格な全時間帯がゼロで並び、比較できます。どの時間帯も不適格なら、正しい結果は status: defer、空の slots リスト、rate: null です。
自己確認:Explorer で results/comparison.csv を開き、全時間帯の理由を見つけてください。プログラムの呼び出しは python3 recommend.py SUMMARY_CSV OUTPUT_DIRECTORY です。固定の時間帯名や固定の勝者に依存せず、別の正しく作成した集計でも動く必要があります。
提案の隣に根拠を示す
このステップでは、欠測をゼロに見せかけずに、合計と率を一緒に表示します。同じ WebIDE ターミナルの /home/labex/project/opening-hours で続けてください。
グラフの約束は最小限です。縦棒のパネルを2つ用意し、横軸の目盛りに時間帯の識別子を置き、縦軸を Total visitors と Visitors per recorded hour とします。不適格なものも含め、全時間帯の観測時間数とカバー率を表示します。合計または率が欠測なら数値の棒を描かず、unknown と表示します。色、フォント、寸法、時間帯順は変更できます。入力が同じなら現在のスクリプトで同じピクセルを再現する、決定的なグラフにしてください。PNG のメタデータは異なっても構いません。ランダムなスタイルや現在時刻を画像に加えないでください。
ループは zip で軸、列名、単位ラベルを1組ずつ取り出します。summary.iterrows() は各時間帯と行を巡回し、enumerate は棒の数値位置を加えます。f-string の :.0% は小数を小数点以下なしの百分率で表示します。pd.notna はカバー率を整形する前に値が存在するか調べます。annotate はデータ位置に文字を配置します。xytext=(0, 5) と textcoords='offset points' で、その位置から上へ5表示ポイント移動します。ha='center' は文字を中央揃えにし、fontsize=7 は根拠のラベルを小さくまとめます。これらの表示設定は計算を変えません。
次のブロックを recommend.py に1回追記します。>> は既存コードを保持します。subplots は2つの軸を作ります。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 は本当のゼロですが、unlogged と closed の率は不明です。不明な値を高さゼロの棒で置き換えてはいけません。
cat results/advice.txt
Decision: compare
Slots: afternoon, evening
Evidence: historical description
Effect of extending hours: not established
提案には4つの公開フィールドを使います。Decision は compare または defer、Slots はカンマ区切りの安定した時間帯 ID または none、Evidence は historical description、Effect of extending hours は not established です。フィールド順と時間帯順は自由で、後に説明を追加できます。短い構造化フィールドで、説得的な文章だけに頼らず、提案を実際の決定と一致させ、その解釈範囲を明示します。
提案は観測済みの過去の機会と明示的なルールに限られます。時間変更が同じ来場者率を引き起こすことは示せません。記録不足の時間帯について決める前に、欠けている記録を集めることが有用な次の行動です。advice.txt は結果を伝えますが、支える根拠は1時間ごとの関係、比較表、決定結果、実際に描いたデータです。
両スクリプト、元の入力 CSV 2つ、hours.csv、summary.csv、comparison.csv、decision.json、実際の PNG と提案を保持します。ファイル名を間違えたら、エラーを読み、ls で探して正しいパスで実行します。追記ではなく置換してしまったら、ステップ1のスクリプトを作り直し、このブロックを1回追加してください。どちらも再実行で入力を変えずに出力を再計算します。
自己確認:グラフの値は現在の集計に基づき、すべての同率候補があり、結果に限界の説明が付いています。チャレンジでは、別の施設の時間帯 ID と根拠が変わりますが、分析ルールは変わりません。
まとめ
比較の適格条件と順位付けを分離し、同率首位をすべて保持して、除外した各時間帯の理由を残しました。2つの実際の棒グラフで、来場者の合計と記録1時間当たりの来場者数を並べ、観測数とカバー率も表示しました。不明な率は不明のままで、提案には過去の記述的データから因果関係を解釈できる範囲の限界を明記しました。
スクリプトは別の施設の結果も再計算できます。正しい分析は、提案を保留したり、複数の同率候補を残したり、実際に来場者が少ない適格な時間帯を比較したりできます。必ず1つの目立つ勝者を選ぶ必要はありません。



