介绍
一个社区展览需要合理比较不同开放时段。总客流更多,可能只是因为可记录客流的机会更多。你将先比较两个观测机会相同的时段,再构建可复用的分析,保留哪些时间开放、哪些有记录,以及哪些仍然未知。
本实验要求掌握 Python 基础、pandas 的读取、筛选与分组,以及 Matplotlib 柱状图。WebIDE 环境已经准备好 Python 和所需库。所有计数都是合成数据;一行表示一个小时,不代表某位访客的身份。时长是包含阅读和普通错误恢复的作者估计。你将生成真实 CSV 和 PNG 文件,并保留全部原始输入。
比较两个观测机会相同的时段
在这一步中,你将先手算客流率,再绘制第一张图。在 WebIDE Tab 中使用底部终端。如果终端隐藏了,请打开左上角菜单,选择 Terminal → New Terminal。命令在这个终端运行,文件显示在左侧 Explorer 中。
进入已准备的工作区。cd 改变当前终端的工作目录,不会改变其他终端。pwd 显示当前目录。如果后面的命令提示文件不存在,回到这里,并用 ls 检查文件名拼写。
cd /home/labex/project/opening-hours
pwd
/home/labex/project/opening-hours
cat 显示文本文件的内容,不会修改文件。这个 CSV 中每个时段都有两个一小时观测。
cat data/equal.csv
slot,visitors
morning,6
morning,10
evening,8
evening,12
上午的两个记录小时共计 16 人:16 / 2 = 8 人/小时。晚间的两个小时共计 20 人:20 / 2 = 10。分母相等,因此总量和每小时比较都支持晚间。客流率始终带有单位:每个记录小时的访客数。
创建下面的小脚本。Shell 的 > 会替换指定文件。<<'PY' 开始一个 here-document:从下一行到只包含 PY 的一行之前,所有内容都按原样保存。请粘贴完整代码块,包括最后一行。文件会立即创建,不需要在编辑器中另行保存。agg 为结果列命名,size 统计观测小时数。index=False 避免把 pandas 行索引写进 CSV。Agg 让 Matplotlib 无需打开独立窗口就能保存 PNG。
cat > equal.py <<'PY'
import pandas as pd
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
data = pd.read_csv('data/equal.csv')
summary = data.groupby('slot').agg(visitors=('visitors', 'sum'), hours=('visitors', 'size'))
summary['rate'] = summary['visitors'] / summary['hours']
summary.to_csv('results/equal-summary.csv')
summary['rate'].plot.bar(color=['#256b5d', '#b35828'], rot=0)
plt.ylabel('Visitors per recorded hour')
plt.title('Equal recording opportunities: two hours each')
plt.tight_layout()
plt.savefig('results/equal.png', dpi=150)
plt.close()
print(summary)
PY
python3 equal.py
visitors hours rate
slot
evening 20 2 10.0
morning 16 2 8.0
行按时段名称排序,显示时行首空白可能不同。在 Explorer 中打开 opening-hours/results/equal.png 查看保存的图。两根竖直柱应分别显示每个记录小时 10 人和 8 人。横轴刻度保留时段名,纵轴标签保留 Visitors per recorded hour;颜色、字体、图尺寸和时段顺序可以不同。PNG 是你实际生成的文件,不是准备好的目标图片。图表必须具有确定性:输入不变时,重新运行当前绘图脚本应得到相同的图像像素。不要使用随机样式或时间戳。PNG 元数据可以不同,你仍可自选颜色、字体和尺寸。
遇到普通文件名错误时,先理解 Python 会报告文件缺失并停止,并没有重置你的工作。下面的失败是有意演示的:
python3 missing.py
你应看到 can't open file 和 No such file or directory。列出工作区,再用正确文件名运行即可恢复。
ls
python3 equal.py
自检:两个时段都使用两个观测小时。你还没有假定以后的所有数据都具有相同的记录机会。
保留每个计划小时
在这一步中,你将把访客计数匹配到对应的计划小时。继续使用位于 /home/labex/project/opening-hours 的 WebIDE 终端。计划中的每一行表示某一天、某个时段的一小时。open=1 表示计划开放,open=0 表示关闭。date 和 slot 共同构成复合键:任何一列都不能单独标识一行。日期使用 YYYY-MM-DD,文件中的日期都是有效日历日期。
cat data/plan.csv
cat data/counts.csv
计数文件故意采用不同顺序。计数 0 是“没有访客”的真实观测。如果某个计划开放小时没有对应计数,表示缺失记录,而不是零客流。关闭小时没有计数行。每条计数都必须匹配一个开放的计划行;两个文件的复合键都必须完整且唯一。不要通过去重或编造计数来掩盖无效键。
左合并(left merge)保留左表即计划表的每一行。它使用两个键列寻找计数,不依赖行的位置。没有匹配的地方得到 pandas 缺失值,显示为 NaN。合并前要检查键是否完整:pandas 可能把缺失键相互匹配,但这不能标识一个真实小时。duplicated(keys) 检测重复键对,validate='one_to_one' 要求合并拒绝重复关系。
脚本使用 .loc[condition, column] 只为匹配条件的行赋值。.eq(0) 和 .eq(1) 逐个比较值是否等于零或一;& 组合条件,notna() 区分有计数和缺失值。先执行关闭条件,再执行开放且有记录的条件,因此真实零值会被认定为已观测。
用下面的完整脚本替换已准备的 observe.py。sys.argv[1] 和 [2] 读取脚本名后面的两个路径参数。Path 与 / 组合构建文件路径。mkdir(parents=True, exist_ok=True) 在需要时创建输出目录,因此同一程序也能分析另一个输入目录。两个输入 CSV 文件始终只读。
cat > observe.py <<'PY'
import sys
from pathlib import Path
import pandas as pd
source = Path(sys.argv[1])
output = Path(sys.argv[2])
plan = pd.read_csv(source / 'plan.csv')
counts = pd.read_csv(source / 'counts.csv')
keys = ['date', 'slot']
if plan[keys].isna().any().any() or counts[keys].isna().any().any():
raise ValueError('Every date and slot must be present')
if plan.duplicated(keys).any() or counts.duplicated(keys).any():
raise ValueError('Every date/slot key must be unique')
if not plan['open'].isin([0, 1]).all():
raise ValueError('open must be 0 or 1')
if counts['visitors'].isna().any() or (counts['visitors'] < 0).any():
raise ValueError('Recorded counts must be nonnegative')
known = counts.merge(plan, on=keys, how='left', validate='one_to_one')
if known['open'].isna().any() or (known['open'] != 1).any():
raise ValueError('Counts must match an open planned hour')
hours = plan.merge(counts, on=keys, how='left', validate='one_to_one')
hours['status'] = 'missing'
hours.loc[hours['open'].eq(0), 'status'] = 'closed'
hours.loc[hours['open'].eq(1) & hours['visitors'].notna(), 'status'] = 'observed'
hours['valid_hour'] = hours['status'].eq('observed').astype(int)
output.mkdir(parents=True, exist_ok=True)
hours.sort_values(keys).to_csv(output / 'hours.csv', index=False)
print(hours[['date', 'slot', 'visitors', 'status']].head())
PY
第一个条件标记关闭小时。第二个条件把具有计数的开放小时标记为已观测,包括真实零值。& 组合两个逐元素条件,notna() 判断值是否存在。valid_hour 把已观测条件转为 1 或 0,求和后即可得到可用观测小时数。没有把缺失计数填成零。
python3 observe.py data results
head -n 10 results/hours.csv
head -n 10 显示表头和前九条数据行。保存的 CSV 包含 date,slot,open,visitors,status,valid_hour,按复合键排序。也在 Explorer 中查看它:quiet 有真实零记录,状态是 observed,且 valid_hour=1;unlogged 的访客计数为空,状态是 missing,且 valid_hour=0;closed 的计数为空,状态是 closed。空白根据状态表示未知或不适用,绝不是猜测的零值。
自检:每个计划行在 hours.csv 中恰好出现一次,每条计数仍与自己的日期和时段关联。如果出现 ValueError,先读清哪种关系无效。对照此代码块和未修改的源文件检查脚本,修正脚本后重新运行。无需重新打开终端。
计算客流率与记录覆盖率
在这一步中,你将汇总各时段,同时保留未知计数的原有含义。继续使用同一个 WebIDE 终端和工作区。
对于 evening,计划有四个开放小时,其中三个小时各记录到 12 位访客。总量是 36,观测小时数是 3,每小时客流率为 36 / 3 = 12,覆盖率为 3 / 4 = 0.75,即计划开放小时中有 75% 获得记录。缺测小时不计入客流率的分母,但仍会降低覆盖率。morning 在四个观测小时记录了 40 人:总量更高,客流率却更低,为 10。
对于 quiet,四个真实零记录得到总量 0、客流率 0,且覆盖完整。对于 unlogged,没有任何小时被观测:覆盖率为 0,但总量和客流率仍为空。对于 closed,计划开放小时数为 0:覆盖率和客流率都没有定义,保持为空。后续不能把未定义的值画成高度为零的客流率柱。
将下面的代码块向 observe.py 追加一次。这里的 >> 表示追加,而不是替换。它使用前面创建的 hours 和 output 变量。sum(min_count=1) 在所有值都缺失时返回缺失总量,而默认求和在全部缺失、没有有效观测值时会返回零。replace(0, float('nan')) 在除法前把零分母明确设为缺失。这些操作仍然保留真实观测到的零分子。
cat >> observe.py <<'PY'
grouped = hours.groupby('slot')
summary = grouped.agg(planned_hours=('open', 'sum'), observed_hours=('valid_hour', 'sum'))
summary['visitors'] = grouped['visitors'].sum(min_count=1)
summary['coverage'] = summary['observed_hours'] / summary['planned_hours'].replace(0, float('nan'))
summary['rate'] = summary['visitors'] / summary['observed_hours'].replace(0, float('nan'))
summary.to_csv(output / 'summary.csv')
print(summary)
PY
python3 observe.py data results
cat results/summary.csv
slot,planned_hours,observed_hours,visitors,coverage,rate
afternoon,3,3,36.0,1.0,12.0
closed,0,0,,,
evening,4,3,36.0,0.75,12.0
morning,4,4,40.0,1.0,10.0
quiet,4,4,0.0,1.0,0.0
short,4,1,20.0,0.25,20.0
sparse,5,3,27.0,0.6,9.0
unlogged,4,0,,0.0,
CSV 中的数字可以写成 36 或 36.0 等不同形式而不改变结果。必需列及其含义保持一致。short 的观测客流率最高,但仅有一个观测小时。sparse 有三个观测,却只覆盖计划小时的 60%。这些都是提出建议前需要考虑证据质量的原因;本实验尚未宣布其中任何时段获胜。
在 Explorer 中打开 results/summary.csv。保留 data/plan.csv、data/counts.csv、data/equal.csv 和脚本。可复用调用约定是 python3 observe.py INPUT_DIRECTORY OUTPUT_DIRECTORY:两个 CSV 都从输入目录读取,hours.csv 和 summary.csv 都写入输出目录。只要保留全部键与数值关系,允许等价计算和不同输出行顺序。如果追加时误把脚本替换掉,请重新创建步骤 2 的脚本,再追加本步骤代码一次,然后重新运行。
自检:完全没有观测的时段,与确实观测到零客流的时段,其总量、分母和客流率不同。下一项实验将利用这些区别,判断什么时候证据足以支持时段比较。
总结
你创建了观测机会相同的比较图,使用完整复合键关联计数,并生成逐小时和逐时段 CSV 文件。关闭时间、缺失记录和真实零值仍然彼此区分。客流率使用实际有记录的开放小时;覆盖率揭示计划开放机会中有多少真正获得记录。
保留可复用分析和原始数据。这些历史描述性客流率不能证明延长开放时间后会发生什么。下一项实验将在独立环境中,先应用明确的证据规则,再提出建议。



