Pythonのアドホック分析の型を考える

# はじめに

データサイエンティストの業務の中では、集計や可視化、簡易的なモデリングなど中心としたアドホックな分析を行うことがありますが、アドホック分析で品質を保つことは難しいと感じます。

第一に、アドホック分析は探索的な要素を多分に含むので、分析のスコープが変動することが多いです。例えば、集計の切り口やモデルのパラメータの設定を変更した上で、分析を再度実行し直すような場面に遭遇したりします。加えて、多くの場合データが綺麗ではありません。当然利用するデータソースに依存しますが、状況によっては整備されていないExcelファイルをベースに分析することもあったりします。

このような点でアドホック分析で品質を保つことは難しく、ともすると書き捨てのnotebookの山になってしまいがちです。本記事では、このような状況において少しでも品質を保ちつつ分析を行う方法について整理します。

# 方法

プロジェクトのディレクトリ構成の例。以下ではこのディレクトリに沿って説明します。

```
.
│
├── data/  # データの格納場所
│   ├── raw/  # 生データ
│   ├── processed/  # クレンジング済みデータ
│   └── datamart/  # 分析用の加工済データ
│
├── src/  # 再利用可能なモジュール
│   ├── data_processor/
│   │   ├── __init__.py
│   │   ├── process.py  # データの前処理
│   │   ├── schema.py  # 前処理済みデータのスキーマ
│   │   └── loader.py  # 前処理済みデータを読み込む処理
│   ├── aggregate/
│   ├── visualize/
│   └── ...
│
├── tests/  # モジュールのテストコード
├── notebook/  # アドホックな分析コード
├── outputs/  # 分析結果の出力場所
├── poetry.lock
├── pyproject.toml
└── README.md
```

## データの品質担保

### panderaでデータの品質を担保する

多くの場合生データは汚く分析に耐えられるものではないため、前処理が必要になります。[pandera](https://pandera.readthedocs.io/en/stable/)は前処理時のデータの品質担保に役立ちます。

panderaは`pd.DataFrame`に対するバリデーションツールであり、データフレームが定義したスキーマに合致するかどうかをチェックできます。具体的には、データフレームをI/Oとして持つような関数に対して

1. `@pa.check_types`でデコレーションして
2. クラスとして定義したスキーマを型ヒントとして付与する

ことで、I/Oとなるデータフレームが定義したスキーマに従っているかどうかをバリデーションできます。

データの前処理の過程でpanderaのバリデーションを通すことで、処理済みのデータが期待したスキーマに従うことを保証できるため、データの品質を一定担保できます。

```python
# src/data_processor/schema.py

import numpy as np
import pandera as pa
from pandera import DataFrameModel
from pandera.typing import Series

class SalesDataSchema(DataFrameModel):
    datetime: Series[np.datetime64]
    user_id: Series[int]
    item_id: Series[int]
    quantity: Series[int] = pa.Field(ge=0)
```

```python
# src/data_processor/process.py

import pandas as pd
import pandera as pa
from pandera.typing import DataFrame
from data_processor.schema import SalesDataSchema

@pa.check_types
def process_sales_data(df_raw: pd.DataFrame) -> DataFrame[SalesDataSchema]:
    df_processed = df_raw.copy()
    df_processed = ...  # 前処理
    return df_processed

def main():
    df_raw = pd.read_csv(REPO_ROOT / "data" / "raw" / "sales_data.csv")
    df_processed = process_sales_data(df_raw)
    df_processed.to_csv(REPO_ROOT / "data" / "processed" / "sales_data.csv", index=False)

if __name__ == "__main__":
     main()
```

また、分析コード上で前処理済みのデータをロードする際にも、panderaでバリデーション付きの関数を定義し、それを通して読み込むことで、バリデーションの保証がついたデータをロードすることができて安全です。

```python
# src/data_processor/loader.py

from pathlib import Path
import pandas as pd
import pandera as pa
from pandera.typing import DataFrame
from .schema import SalesDataSchema

@pa.check_types
def load_sales_data(filepath: Path) -> DataFrame[SalesDataSchema]:
    df = pd.read_csv(filepath)
    df["datetime"] = pd.to_datetime(df["datetime"])
    return df
```

```python
# notebook/some_analysis.py

from data_processor.loader import load_sales_data

df = load_sales_data(REPO_ROOT / "data" / "processed" / "sales_data.csv")
```

### データマートに重要な処理を集約させる

前処理の段階ではデータマートを作成し、そこに頻繁に参照する処理を集約させることが望ましいです。前処理が不十分な場合、各分析コードに重複する集計ロジックが散逸してしまい、品質の維持が難しくなるだけでなく、作業の効率性も損なわれます。

データマートを作成する上では、一般的なデータ基盤のようにテーブルを層別に分けて管理する考え方が参考になります[^1]。例えば、以下のような3層に分けて管理します。

[^1]: データ管理の考え方は「[実践的データ基盤への処方箋](https://gihyo.jp/book/2021/978-4-297-12445-8)」が参考になりました。

1. **データレイク層：**`data/raw/`
整備されていない生データを配置する。

2. **クレンジング層：**`data/processed/`
生データから必要な情報を落とさずにクレンジング（欠損補完や異常値処理など）した前処理済みデータを配置する。スキーマはpanderaで管理する。

3. **データマート層：**`data/datamart/` 
クレンジング層のデータに対して集約や結合、カラムの追加などを行い、分析に利用する形に整形したデータを配置する。重要な集計ロジックはここに集約させる。クレンジング層同様スキーマはpanderaで管理する。

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2988692/4e707ad0-75e3-faf5-ca6b-41cd039e89d0.png)

実際には、データソースの種類・量が少ない場合にこのように複数の層で管理するのは過剰な場合もあるため、求められる品質やコストと相談しつつ柔軟に対応することになります。いずれにせよ、頻繁に利用する処理が各分析コードに散逸しないよう、前処理に集約させておくことが重要です。

## コードの品質担保

### ノートブックはipynbで書かない

アドホック分析、特に集計や可視化などの作業などを行う際に、対話的に実行できるipynbファイルは重宝します。しかしその一方で、ipynbファイルはGit管理に向いていないため、まともに差分管理やコードレビューができず、コードの品質を管理する上でかなり辛いです。

そこで役立つのが、VScodeの[Python Interactirve Window](https://code.visualstudio.com/docs/python/jupyter-support-py)の機能です。pyファイル上で`# %%`というコメントを書くと、それによって囲まれた部分がコードセルとして認識され、通常のipynbファイルと同様に`Shift + Enter`で対話的に実行することができます[^2]。この機能を使うと、pyファイルベースでインタラクティブにコーディングができ、かつGit管理も捗ります。

[^2]: jupyterのインストールは必要です。

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2988692/6af000a4-df12-09b3-12df-628619a7ef81.png)
（画像は[公式ドキュメントから](https://code.visualstudio.com/docs/python/jupyter-support-py)引用）

Interactive Windowではipynbファイルと同様、デバッガも利用できます。また、HTMLでの実行結果のエクスポートもできるため、ノートブックそのものをレポーティングに使いたい場合にも対応できます。さらに、pyファイルであるため必要であればスクリプトとして実行することも可能です。

### 設定値を一か所にまとめる

アドホック分析は探索的な要素を含むため、パラメータを変更して再度分析する場面がそれなりにあります。そのような状況にも対応できるよう、ノートブック上で変更しうる設定値を一つの場所にまとめておくことが望ましいです。最も簡単な方法は、以下のようにノートブックの最上部でdataclassなどを用いてConfigオブジェクトとしてまとめておくことです。設定ファイルとして分離してまとめておくのも手です。

```python
# %%
from dataclasses import dataclass, field
from pathlib import Path
from data_processor.loader import load_datamart

# %%
@dataclass
class Config:
    data_dir: Path = REPO_ROOT / "data" / "datamart"
    output_dir: Path = REPO_ROOT / "outputs"
    group: list[str] = field(default_factory=lambda : ["user_id"])
config = Config()

# %%
df = load_datamart(config.data_dir / "datamart.csv")
df_result = df.groupby(config.group)["sales_price"].mean().reset_index()
df_result.to_csv(config.output_dir / "output.csv", index=False)
```

複数の異なる設定値で分析を実行し、その結果を管理したい場合は[Hydra](https://hydra.cc/docs/intro/)が便利です。以下のように、実行する処理を`@hydra`で修飾してスクリプトとして実行すると、コマンドライン引数として与えたパラメータの全ての組み合わせに対して処理が実行され、かつ自動的に生成されるディレクトリに出力ファイルを放り込んでくれます。

```python
# kmeans_sample.py

from dataclasses import dataclass
import hydra
from hydra.core.config_store import ConfigStore
from sklearn.cluster import KMeans

@dataclass
class Config:
    n_clusters: int = 5
    algorithm: str = "lloyd"

cs = ConfigStore.instance()
cs.store(name="config", node=Config)

@hydra.main(version_base="1.1", config_name="config")
def main(config: Config):
    kmeans = KMeans(**config)
    ...  # 分析コード

if __name__ == "__main__":
    main()

```

```bash
python kmeans_sample.py -m n_clusters=3,4,5
```

### 共通する処理は切り出しモジュール化する

アドホック分析といえど、複数の分析コード上にまたがって頻繁に利用する処理が出てくることがあります。そのような処理は積極的に切り出して`src/`以下でモジュール化します。更に言うと、ノートブック上では可能な限り、共通化ができないようなその場限りの処理だけを書くように心がけます。共通する処理を再利用可能にすることで、長期的に見て品質や効率の向上につながります。

モジュール化する際には再利用性を意識し、可能な限りコンテキストに依存しないような汎用的な関数を書くようにします。

# まとめ

アドホックな分析はスコープの定義がしづらく、それゆえ明確なルールを定めることが難しいですが、その中でも品質を向上させるための基本的な考え方について整理しました。実際のアドホック分析ではスピードが求められることが多いため、状況に応じてQCDのバランスを取りつつ、過剰品質にならない範囲で以上のような工夫を組み込んでいければよいのではないかと思います。

# 参考

https://pandera.readthedocs.io/en/stable/

https://code.visualstudio.com/docs/python/jupyter-support-py

https://hydra.cc/docs/intro/