コンテンツにスキップ

第10回 時空間データキューブ

科目:ビッグデータ応用Ⅱ〔特徴量エンジニアリング〕 担当:小野原彩香 対象:3年生(後期)


学習目標

この回を終えると、以下のことができるようになります。

  1. 時空間データキューブの3次元構造(x, y, time)を説明できる
  2. Polarsを使ってLLMログデータを時間ビンごとに集計できる
  3. xarrayでデータキューブを構築し、時系列スライスを取り出せる
  4. 折れ線グラフとヒートマップカレンダーで変化を可視化できる
  5. DuckDBの時空間SQLクエリを記述できる

1. 時空間データキューブとは

1.1 基本概念

通常の地理データは「空間(x, y)」の2次元です。 これに「時間(t)」を加えた3次元の構造を時空間データキューブと呼びます。

         time →
        t1  t2  t3  t4
  y↑  ┌───────────────┐
      │ v  v  v  v   │  ← ある(x,y)セルの時系列値
      │ v  v  v  v   │
      │ v  v  v  v   │
  y↓  └───────────────┘
       x →

各セル(x, y, t)には、そのグリッドセル・その時刻における集計値が入ります。

例:地理空間LLMログデータの場合 - x軸(経度):東西方向のグリッドセルID - y軸(緯度):南北方向のグリッドセルID - time軸:時間ビン(1時間単位、1日単位、1週単位など) - 値:クエリ数、エラー数、平均レイテンシ、ユニークユーザー数 など

1.2 なぜ時空間キューブが有用か

時空間キューブを使うと、以下のような問いに答えられます。

  • 「ある地域のクエリ数は、曜日によって変化するか?」
  • 「エラーが多発している時間帯と地域はどこか?」
  • 「特定のイベント(祭り、災害)後に利用パターンはどう変わったか?」

これらは空間データだけでも時系列データだけでも答えられません。両方を組み合わせることで初めて分析できます。

1.3 時間ビンの種類

ビン単位 用途 DuckDB式
1時間 日中パターンの把握 date_trunc('hour', ts)
1日 曜日パターンの把握 date_trunc('day', ts)
1週 週次トレンド date_trunc('week', ts)
1月 季節性 date_trunc('month', ts)

2. データキューブの構築

2.1 LLMログデータの構造

data/llm_geo_logs.parquet
├── timestamp       : タイムスタンプ(datetime)
├── longitude       : 経度(float64)
├── latitude        : 緯度(float64)
├── query_text      : クエリテキスト(string)
├── response_time_ms: 応答時間(float64, ミリ秒)
├── is_error        : エラーフラグ(bool)
├── error_type      : エラー種別(string)
├── user_id         : 匿名化ユーザーID(string)
└── grid_id         : グリッドセルID(string, 例: "H36_V22")

2.2 グリッドセルの定義

緯度経度を均等なグリッドに変換します。

import polars as pl
import numpy as np

# グリッドサイズ(度)
GRID_SIZE = 0.01  # ≈ 1km

def assign_grid(df: pl.DataFrame, lon_col: str, lat_col: str,
                grid_size: float = 0.01) -> pl.DataFrame:
    """
    緯度経度を指定グリッドサイズのセルIDに変換する
    """
    return df.with_columns([
        (pl.col(lon_col) / grid_size).floor().cast(pl.Int32).alias('grid_x'),
        (pl.col(lat_col) / grid_size).floor().cast(pl.Int32).alias('grid_y'),
    ]).with_columns(
        (pl.col('grid_x').cast(pl.Utf8) + '_' +
         pl.col('grid_y').cast(pl.Utf8)).alias('grid_id')
    )

3. 時系列集計と変化検出

3.1 Polarsによる時系列集計

Polarsはpandasより高速な列指向データフレームライブラリです。 特に大量のログデータ(数百万行)の集計に適しています。

import polars as pl
from datetime import datetime

# データ読み込み
df = pl.read_parquet('data/llm_geo_logs.parquet')
print(f"レコード数: {len(df):,}")
print(df.head())

# タイムスタンプを時間ビンに丸める
df = df.with_columns([
    pl.col('timestamp').dt.truncate('1h').alias('hour_bin'),
    pl.col('timestamp').dt.truncate('1d').alias('day_bin'),
    pl.col('timestamp').dt.weekday().alias('weekday'),  # 0=月曜
    pl.col('timestamp').dt.hour().alias('hour_of_day'),
])

# グリッドセルIDを付与
df = assign_grid(df, 'longitude', 'latitude', GRID_SIZE)

3.2 時間×グリッドの集計

# 日×グリッドで集計(データキューブの断面を作る)
cube_daily = (
    df
    .group_by(['day_bin', 'grid_id', 'grid_x', 'grid_y'])
    .agg([
        pl.len().alias('query_count'),
        pl.col('is_error').sum().alias('error_count'),
        pl.col('is_error').mean().alias('error_rate'),
        pl.col('response_time_ms').mean().alias('mean_response_ms'),
        pl.col('response_time_ms').quantile(0.95).alias('p95_response_ms'),
        pl.col('user_id').n_unique().alias('unique_users'),
    ])
    .sort(['day_bin', 'grid_id'])
)

print("日次集計結果:")
print(cube_daily.head(10))
print(f"\n形状: {cube_daily.shape}")

3.3 変化検出:前日比の計算

# 特定のグリッドセルの時系列を取得
target_grid = 'H36_V22'

ts_target = (
    cube_daily
    .filter(pl.col('grid_id') == target_grid)
    .sort('day_bin')
    .with_columns([
        pl.col('query_count').shift(1).alias('query_count_prev'),
        pl.col('error_rate').shift(1).alias('error_rate_prev'),
    ])
    .with_columns([
        ((pl.col('query_count') - pl.col('query_count_prev')) /
          pl.col('query_count_prev') * 100).alias('query_change_pct'),
        (pl.col('error_rate') - pl.col('error_rate_prev')).alias('error_rate_change'),
    ])
)

print("変化検出結果(最初の10日):")
print(ts_target.select(['day_bin', 'query_count', 'query_change_pct',
                         'error_rate', 'error_rate_change']).head(10))

3.4 移動平均による平滑化

# 7日移動平均(ノイズ除去)
ts_smoothed = (
    ts_target
    .with_columns([
        pl.col('query_count')
          .rolling_mean(window_size=7, min_periods=3)
          .alias('query_count_7d_ma'),
        pl.col('error_rate')
          .rolling_mean(window_size=7, min_periods=3)
          .alias('error_rate_7d_ma'),
    ])
)

4. 可視化

4.1 折れ線グラフ(時系列トレンド)

import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd

plt.rcParams['font.family'] = 'Hiragino Sans'
plt.rcParams['axes.unicode_minus'] = False

# Polars → Pandasに変換して可視化
ts_pd = ts_smoothed.to_pandas()

fig, axes = plt.subplots(2, 1, figsize=(14, 8), sharex=True)

# 上段:クエリ数
axes[0].fill_between(ts_pd['day_bin'], ts_pd['query_count'],
                      alpha=0.3, color='steelblue', label='実測値')
axes[0].plot(ts_pd['day_bin'], ts_pd['query_count_7d_ma'],
             color='navy', lw=2, label='7日移動平均')
axes[0].set_ylabel('クエリ数')
axes[0].set_title(f'グリッド {target_grid} の時系列変化')
axes[0].legend()

# 下段:エラー率
axes[1].fill_between(ts_pd['day_bin'], ts_pd['error_rate'] * 100,
                      alpha=0.3, color='crimson', label='エラー率(実測)')
axes[1].plot(ts_pd['day_bin'], ts_pd['error_rate_7d_ma'] * 100,
             color='darkred', lw=2, label='7日移動平均')
axes[1].set_ylabel('エラー率(%)')
axes[1].set_xlabel('日付')
axes[1].legend()

# x軸の日付フォーマット設定
for ax in axes:
    ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d'))
    ax.xaxis.set_major_locator(mdates.WeekdayLocator(interval=2))

plt.tight_layout()
plt.savefig('output/timeseries_target_grid.png', dpi=150, bbox_inches='tight')
plt.show()

4.2 ヒートマップカレンダー

# 時刻×曜日のヒートマップ
heatmap_data = (
    df
    .group_by(['weekday', 'hour_of_day'])
    .agg([
        pl.len().alias('query_count'),
        pl.col('is_error').mean().alias('error_rate'),
    ])
    .sort(['weekday', 'hour_of_day'])
    .to_pandas()
)

# ピボットテーブル化
heatmap_pivot = heatmap_data.pivot(
    index='weekday', columns='hour_of_day', values='query_count'
)

weekday_labels = ['月', '火', '水', '木', '金', '土', '日']

import seaborn as sns
fig, axes = plt.subplots(2, 1, figsize=(16, 8))

# クエリ数ヒートマップ
heatmap_error = heatmap_data.pivot(
    index='weekday', columns='hour_of_day', values='error_rate'
)

sns.heatmap(heatmap_pivot, ax=axes[0], cmap='YlOrRd',
            yticklabels=weekday_labels, linewidths=0.5,
            cbar_kws={'label': 'クエリ数'})
axes[0].set_title('曜日×時間帯別 クエリ数ヒートマップ(全グリッド合計)')
axes[0].set_xlabel('時刻(0〜23時)')
axes[0].set_ylabel('曜日')

sns.heatmap(heatmap_error * 100, ax=axes[1], cmap='coolwarm',
            yticklabels=weekday_labels, linewidths=0.5,
            fmt='.1f', annot=True,
            cbar_kws={'label': 'エラー率(%)'})
axes[1].set_title('曜日×時間帯別 エラー率ヒートマップ')
axes[1].set_xlabel('時刻(0〜23時)')
axes[1].set_ylabel('曜日')

plt.tight_layout()
plt.savefig('output/heatmap_calendar.png', dpi=150, bbox_inches='tight')
plt.show()

4.3 xarrayによるデータキューブ構築と空間スライス

import xarray as xr

# Polars → Pandas経由でxarrayに変換
cube_pd = cube_daily.to_pandas()

# グリッドの範囲を特定
grid_x_vals = sorted(cube_pd['grid_x'].unique())
grid_y_vals = sorted(cube_pd['grid_y'].unique())
day_vals = sorted(cube_pd['day_bin'].unique())

# xarrayデータセット構築
query_cube = cube_pd.pivot_table(
    index='day_bin', columns=['grid_y', 'grid_x'],
    values='query_count', fill_value=0
)

# xarray DataArray として整形
import itertools
ds = xr.Dataset()

for var in ['query_count', 'error_rate', 'mean_response_ms']:
    pivot = cube_pd.pivot_table(
        index='day_bin',
        columns=['grid_y', 'grid_x'],
        values=var,
        fill_value=np.nan
    )
    # MultiIndex を展開
    data_array = xr.DataArray(
        pivot.values.reshape(
            len(day_vals), len(grid_y_vals), len(grid_x_vals)
        ),
        dims=['time', 'y', 'x'],
        coords={
            'time': day_vals,
            'y': grid_y_vals,
            'x': grid_x_vals,
        },
        name=var
    )
    ds[var] = data_array

print("データキューブの構造:")
print(ds)

# 特定の日のスナップショット取得
snapshot = ds['query_count'].sel(time='2024-03-15')
print(f"\n2024-03-15のスナップショット形状: {snapshot.shape}")

# 特定グリッドの時系列取得
ts_query = ds['query_count'].sel(x=136, y=35, method='nearest')
print(f"\n時系列の長さ: {len(ts_query)}")

5. ハンズオン手順

5.1 環境確認

# ライブラリのバージョン確認
python -c "import polars; print('Polars:', polars.__version__)"
python -c "import xarray; print('xarray:', xarray.__version__)"
python -c "import duckdb; print('DuckDB:', duckdb.__version__)"

5.2 DuckDBを使った時空間SQL集計

import duckdb

# DuckDBでインメモリ分析
conn = duckdb.connect()

# Parquetファイルを直接読み込む(コピー不要)
conn.execute("INSTALL spatial; LOAD spatial;")

# 時間帯別×グリッド別集計クエリ
result = conn.execute("""
    SELECT
        date_trunc('hour', timestamp) AS hour_bin,
        floor(longitude / 0.01) AS grid_x,
        floor(latitude  / 0.01) AS grid_y,
        COUNT(*)                              AS query_count,
        SUM(is_error::INTEGER)                AS error_count,
        AVG(is_error::FLOAT)                  AS error_rate,
        AVG(response_time_ms)                 AS mean_response_ms,
        PERCENTILE_CONT(0.95) WITHIN GROUP
            (ORDER BY response_time_ms)       AS p95_response_ms
    FROM read_parquet('data/llm_geo_logs.parquet')
    WHERE timestamp >= '2024-01-01'
      AND timestamp <  '2024-04-01'
    GROUP BY ALL
    ORDER BY hour_bin, grid_x, grid_y
""").pl()  # .pl() でPolars DataFrameとして取得

print("DuckDB集計結果:")
print(result.head(10))
print(f"\nレコード数: {len(result):,}")

5.3 空間フィルタリングクエリ

# 札幌都心部のみ抽出(ST_Contains使用)
result_sapporo = conn.execute("""
    SELECT
        date_trunc('day', timestamp) AS day_bin,
        COUNT(*)                     AS query_count,
        AVG(is_error::FLOAT)         AS error_rate
    FROM read_parquet('data/llm_geo_logs.parquet')
    WHERE ST_Contains(
        ST_GeomFromText('POLYGON((141.30 43.02, 141.42 43.02,
                                   141.42 43.12, 141.30 43.12,
                                   141.30 43.02))'),
        ST_Point(longitude, latitude)
    )
    GROUP BY day_bin
    ORDER BY day_bin
""").pl()

print("札幌都心部の日次集計:")
print(result_sapporo.head())

5.4 Parquetへの保存

# 集計結果をParquetで保存(次回以降の分析で再利用)
cube_daily.write_parquet('data/cube_daily.parquet')
result.write_parquet('data/cube_hourly.parquet')

print("保存完了:")
import os
for f in ['data/cube_daily.parquet', 'data/cube_hourly.parquet']:
    size_mb = os.path.getsize(f) / 1024 / 1024
    print(f"  {f}: {size_mb:.1f} MB")

6. 演習課題

課題1(基本)

data/llm_geo_logs.parquetを読み込み、以下を実行してください。

  1. Polarsで「週×グリッドセル」の集計テーブルを作成する
  2. 最もクエリ数が多かったグリッドセルTOP10を表示する

課題2(応用)

  1. 曜日×時間帯のヒートマップを「エラー率」で作成する
  2. エラー率が特に高い時間帯・曜日を3つ特定し、その理由を推測してください(コメントとして記述)

課題3(発展)

  1. DuckDBの空間クエリ(ST_Distance)を使って、「各グリッドセルから最寄りの鉄道駅までの距離」を計算する
  2. 鉄道駅データ:data/train_stations.parquet(longitude, latitude列あり)
  3. 距離帯(0-500m, 500m-1km, 1km-5km, 5km以上)別のエラー率を比較するグラフを描く

提出形式:Jupyterノートブック、ファイル名:10_spatiotemporal_cube_[学籍番号].ipynb


まとめ

項目 ポイント
時空間キューブ (x, y, time)の3次元構造で時空間パターンを表現
Polarsの活用 遅延評価・列指向処理で大規模ログの高速集計
時間ビン 分析目的に応じて時間・日・週単位を使い分ける
xarray 多次元配列として扱い、スライス・インデックスが容易
DuckDBの強み Parquetを直接SQLで操作、空間関数も利用可能
可視化の選択 折れ線グラフ(トレンド)・ヒートマップ(パターン)を使い分ける
次回への接続 今回作成した集計特徴量を第11回のk-meansクラスタリングに活用する

次回予告: 第11回では、空間特徴量に基づいてグリッドセルを類型化(ゾーニング)するk-meansクラスタリングを実施します。


作成:小野原彩香 / ビッグデータ応用Ⅱ〔特徴量エンジニアリング〕