比較可能な1時間単位の観測データを作る

PythonBeginner
オンラインで実践に進む

はじめに

地域の展示施設で、開館時間帯を適切に比較する必要があります。来場者の合計が多いのは、単に記録する機会が多かったためかもしれません。まず観測時間数が等しい2つの時間帯を比較し、次に、開館していた時間、記録がある時間、依然として不明な値を区別して保持する再利用可能な分析を作ります。

このラボは、Python の基礎、pandas の読み込み・絞り込み・グループ化、Matplotlib の棒グラフを前提とします。WebIDE には Python と必要なライブラリが用意されています。計数はすべて合成データで、1行は1時間を表し、個人の識別情報ではありません。所要時間は、読む時間と通常の誤りからの復旧を含む作者の見積もりです。元の入力をすべて保持しながら、実際の CSV と PNG ファイルを作成します。

観測時間数が等しい2つの時間帯を比べる

このステップでは、最初のグラフを描く前に率を手計算します。WebIDE Tab の下部のターミナルを使ってください。見えない場合は左上のメニューを開き、Terminal → New Terminal を選択します。コマンドはこのターミナルで実行され、ファイルは左側の Explorer に表示されます。

用意された作業領域に移動します。cd はこのターミナルの作業ディレクトリを変更し、別のターミナルには影響しません。pwd は現在のディレクトリを表示します。後でファイルが見つからないと表示されたら、ここに戻り、ls で名前のつづりを確認してください。

cd /home/labex/project/opening-hours
pwd
/home/labex/project/opening-hours

cat はテキストファイルを変更せずに表示します。この CSV には、時間帯ごとに1時間の観測が2つあります。

cat data/equal.csv
slot,visitors
morning,6
morning,10
evening,8
evening,12

朝は記録のある2時間で合計16人なので、16 / 2 = 8 人/時です。夕方は2時間で20人なので、20 / 2 = 10 です。分母が等しいため、合計でも時間当たりでも夕方が上回ります。率には「記録された1時間当たりの来場者数」という単位があります。

次の短いスクリプトを作成します。シェルの > は指定したファイルを置き換えます。<<'PY' はヒアドキュメントの開始で、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 を開き、保存されたグラフを見てください。2本の縦棒は、記録された1時間当たり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

自己確認:どちらの時間帯も2時間の観測を使っています。今後のすべてのデータで記録機会が等しいとは、まだ仮定していません。

計画されたすべての1時間を保持する

このステップでは、来場者数を対応する計画時間に結び付けます。/home/labex/project/opening-hours にある WebIDE のターミナルで続けてください。計画の各行は、ある日付のある時間帯の1時間です。open=1 は開館予定、open=0 は閉館を意味します。date と slot の組が複合キーです。どちらか一方の列だけでは行を識別できません。日付形式は YYYY-MM-DD で、ファイル内の日付はすべて有効な暦日です。

cat data/plan.csv
cat data/counts.csv

計数ファイルは意図的に別の順序になっています。0 は来場者がいなかったという実際の観測です。開館予定の1時間に対応する計数がない場合は欠測であり、来場者ゼロではありません。閉館時間には計数行がありません。各計数は、開館予定の計画行に対応しなければなりません。両ファイルの複合キーは、欠損がなく一意である必要があります。無効なキーを隠すために重複を削除したり、計数を作り上げたりしないでください。

左結合(left merge)は左の表、つまり計画表の全行を保持します。行の位置ではなく、キーの2列を使って計数を探します。対応がない行には pandas の欠損値が入り、NaN と表示されます。結合の前にキーがそろっているか確認します。pandas は欠けたキー同士を対応させる場合がありますが、それでは実際の1時間を識別できません。duplicated(keys) は重複するキーの組を検出し、validate='one_to_one' は重複関係の拒否を要求します。

スクリプトは .loc[condition, column] を使って条件に合う行だけに代入します。.eq(0) と .eq(1) は各値をゼロまたは1と比較し、& は条件を組み合わせ、notna() は値があるか欠けているかを区別します。最初に閉館のマスクを適用し、その後に開館かつ記録ありのマスクを適用するため、実際のゼロも観測済みになります。

用意された observe.py を次の完全なスクリプトで置き換えます。sys.argv[1] と [2] は、スクリプト名の後の2つのパス引数を読み取ります。Path と / の組み合わせでファイルパスを作ります。mkdir(parents=True, exist_ok=True) は必要に応じて出力ディレクトリを作るため、同じプログラムで別の入力ディレクトリも分析できます。2つの入力 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

最初のマスクは閉館時間を示します。2つ目は計数がある開館時間を観測済みとし、実際のゼロも含めます。& は要素ごとの2条件を組み合わせ、notna() は値が存在するかを調べます。valid_hour は観測済みの条件を 1 または 0 に変換し、その合計で使える観測時間数を数えられます。欠測した計数をゼロで埋めてはいません。

python3 observe.py data results
head -n 10 results/hours.csv

head -n 10 はヘッダーと最初の9データ行を表示します。保存した CSV には date,slot,open,visitors,status,valid_hour があり、複合キー順に並んでいます。Explorer でも確認してください。quiet の記録済みゼロは observed かつ valid_hour=1、unlogged の空欄の来場者数は missing かつ valid_hour=0、closed の空欄の計数は closed です。空欄は状態に応じて不明または対象外を意味し、推測したゼロではありません。

自己確認:計画の各行が hours.csv にちょうど1回現れ、各計数が自分の日付と時間帯に対応しています。ValueError が出たら、どの関係が無効なのかを読みます。このコードと変更していない入力ファイルを基準にスクリプトを見直し、修正後に再実行してください。ターミナルを開き直す必要はありません。

時間当たりの率と記録カバー率を計算する

このステップでは、不明な計数の意味を変えずに時間帯ごとに集計します。同じ WebIDE ターミナルと作業領域で続けてください。

evening は4時間の開館予定があり、そのうち3時間にそれぞれ12人の記録があります。合計は36人、観測時間数は3、時間当たりの率は 36 / 3 = 12、カバー率は 3 / 4 = 0.75 です。つまり、開館予定時間の75%が記録されています。欠測時間は率の分母に入りませんが、カバー率を下げます。morning は4観測時間で40人です。合計は大きいものの、率は10と低くなっています。

quiet は4つの記録済みゼロから、完全なカバー率で合計0、率0になります。unlogged は観測した時間がありません。カバー率は0ですが、合計と率は空欄のままです。closed は開館予定時間が0なので、カバー率と率の両方が未定義で、空欄のままです。未定義の値を、後で高さゼロの率の棒にしてはいけません。

次のブロックを observe.py に1回追加します。>> は置換ではなく追記です。上で作った 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 の観測された率は最も高いですが、観測時間は1時間だけです。sparse は3つの観測があっても、計画時間の60%しかカバーしていません。これらは提案前に根拠の質を検討すべき理由です。このラボでは、まだどちらも勝者とはしていません。

Explorer で results/summary.csv を開きます。data/plan.csv、data/counts.csv、data/equal.csv とスクリプトを保持してください。再利用可能な呼び出しの約束は python3 observe.py INPUT_DIRECTORY OUTPUT_DIRECTORY です。2つの CSV を入力から読み取り、hours.csv と summary.csv を出力に書きます。すべてのキーと数値関係を保持すれば、同等の計算や別の出力行順序も認められます。追記のつもりでスクリプトを置き換えてしまった場合は、ステップ2のスクリプトを作り直し、このブロックを1回追加して再実行してください。

自己確認:観測していない時間帯と、実際に観測した結果がゼロだった時間帯は、合計、分母、率が異なります。次のラボではこの区別を使い、比較を支える根拠が十分かどうか判断します。

まとめ

観測機会が等しい比較グラフを作り、完全な複合キーで計数を対応付け、1時間ごとと時間帯ごとの CSV ファイルを作成しました。閉館していた時間、欠測、実際のゼロは区別されたままです。率には記録のある開館時間を使い、カバー率は計画した観測機会のうち実際に記録された割合を示します。

再利用可能な分析と元データを保管してください。これらの過去の記述的な率は、開館時間を延長した後に何が起きるかを証明しません。次の独立した環境では、提案を行う前に明示的な根拠のルールを適用します。