2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

研究でPythonを使い始めた頃、解析コードはほぼすべてJupyter Notebookに書いていました。

Notebookは非常に便利です。

  • データを読み込む
  • グラフを描く
  • パラメータを少し変える
  • 結果をその場で確認する
  • Markdownで考察を書く

このような探索的な作業では、Notebook以上に使いやすい環境はなかなかありません。

しかし、解析が進むにつれて、次のような問題が起き始めました。

  • どのセルをどの順番で実行したか分からない
  • 以前出した図を再生成できない
  • パラメータがNotebook内の各所に散らばる
  • ファイルパスを毎回手で書き換える
  • 修正によって過去の処理が壊れても気づけない
  • 別のデータに同じ解析を適用しにくい
  • 他の人にコードを渡しても、そのままでは動かない

そこで、研究コードを次の構成へ少しずつ移しました。

  • Notebook:結果の確認と考察
  • CLI:解析処理の実行
  • YAML:解析条件の管理
  • pytest:処理が壊れていないことの確認

この記事では、Notebookだけで管理していた研究コードを、CLI・YAML・pytest構成へ移したときの考え方と、最小構成の実装例を紹介します。

完成形は次のような構成です。

research-project/
├── configs/
│   ├── default.yaml
│   └── sample_a.yaml
├── data/
│   ├── raw/
│   └── processed/
├── notebooks/
│   └── result_check.ipynb
├── outputs/
│   ├── figures/
│   └── tables/
├── src/
│   └── research_analysis/
│       ├── __init__.py
│       ├── cli.py
│       ├── config.py
│       ├── io.py
│       └── analysis.py
├── tests/
│   ├── test_analysis.py
│   └── test_config.py
├── pyproject.toml
└── README.md

Notebookだけで解析していた頃

最初は、1つのNotebookにすべてを書いていました。

import pandas as pd
import matplotlib.pyplot as plt

input_path = "../data/sample.csv"
threshold = 5.0
output_path = "../outputs/result.csv"

df = pd.read_csv(input_path)

selected = df[df["signal"] > threshold]
selected["normalized"] = selected["signal"] / selected["signal"].max()

selected.to_csv(output_path, index=False)

plt.hist(selected["normalized"], bins=50)
plt.show()

小規模な解析であれば、これでも問題ありません。

しかし、実際の研究では徐々に処理が増えていきます。

# 一度だけ実行するセル
df = load_data(...)

# 後から追加した補正
df["signal"] = df["signal"] - 0.13

# sample Bではこちらを使う
# threshold = 4.5

threshold = 5.0

# この処理は現在使っているのか不明
df = old_filter(df)

# 途中結果がメモリ上に残っている前提
plot_result(df)

この状態になると、Notebookを上から順番に実行しても同じ結果になるとは限りません。

Jupyter Notebookでは、画面上のセルの順番と、実際に実行した順番が一致しないことがあります。

例えば、画面上では次の順番に並んでいても、

x = 1
print(x)
x = 100

3番目、1番目、2番目の順に実行していれば、表示される値は1になります。

Notebookを開いてコードを読むだけでは、どの状態から結果が生成されたのか分からなくなります。

問題はNotebookそのものではありません。

Notebookに、次の役割をすべて持たせていたことが問題でした。

  • データ処理
  • パラメータ設定
  • ファイル入出力
  • グラフ生成
  • 実行手順
  • 結果の確認
  • 考察

そこで、これらを少しずつ分離しました。

Notebookを捨てるのではなく役割を限定する

今回の移行では、Notebookを完全に廃止したわけではありません。

現在も、次の用途ではNotebookを使っています。

  • データの中身を確認する
  • グラフを試しに描く
  • 新しい解析方法を試す
  • 結果を比較する
  • 考察を書く
  • 論文や発表資料に使う図を検討する

一方で、正式な解析処理はPythonモジュール側へ移しました。

Notebook
    ↓ 試行錯誤
Python関数
    ↓ 実行方法を固定
CLI
    ↓ パラメータを分離
YAML
    ↓ 動作を検証
pytest

Notebookは探索の場所、CLIは再実行の入口、YAMLは条件の記録、pytestは最低限の保証、と役割を分けています。

最初にNotebookの処理を関数へ移す

最初から大規模な設計を目指すと、移行作業そのものが負担になります。

そこで、まずNotebook内の処理を普通のPython関数へ移しました。

# src/research_analysis/analysis.py

import pandas as pd


def select_events(
    df: pd.DataFrame,
    threshold: float,
) -> pd.DataFrame:
    selected = df.loc[df["signal"] > threshold].copy()

    max_signal = selected["signal"].max()

    if pd.isna(max_signal) or max_signal == 0:
        selected["normalized"] = 0.0
    else:
        selected["normalized"] = selected["signal"] / max_signal

    return selected

ファイルの読み書きも分離します。

# src/research_analysis/io.py

from pathlib import Path

import pandas as pd


def load_csv(path: Path) -> pd.DataFrame:
    if not path.exists():
        raise FileNotFoundError(f"Input file not found: {path}")

    return pd.read_csv(path)


def save_csv(df: pd.DataFrame, path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    df.to_csv(path, index=False)

Notebook側では、関数を呼び出すだけにします。

from pathlib import Path

from research_analysis.analysis import select_events
from research_analysis.io import load_csv

df = load_csv(Path("../data/sample.csv"))
result = select_events(df, threshold=5.0)

result.head()

この段階だけでも、Notebook内のコード量がかなり減ります。

また、同じ関数をNotebook以外からも呼び出せるようになります。

CLIを作って実行方法を固定する

次に、コマンドラインから解析を実行できるようにしました。

Python標準ライブラリのargparseを使った最小構成は次のようになります。

# src/research_analysis/cli.py

import argparse
from pathlib import Path

from research_analysis.analysis import select_events
from research_analysis.io import load_csv, save_csv


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Run signal selection analysis."
    )

    parser.add_argument(
        "--input",
        type=Path,
        required=True,
        help="Path to the input CSV file.",
    )

    parser.add_argument(
        "--output",
        type=Path,
        required=True,
        help="Path to the output CSV file.",
    )

    parser.add_argument(
        "--threshold",
        type=float,
        default=5.0,
        help="Signal selection threshold.",
    )

    return parser


def main() -> None:
    parser = build_parser()
    args = parser.parse_args()

    df = load_csv(args.input)
    result = select_events(df, threshold=args.threshold)
    save_csv(result, args.output)

    print(f"Input rows : {len(df)}")
    print(f"Output rows: {len(result)}")
    print(f"Saved to   : {args.output}")


if __name__ == "__main__":
    main()

実行するときは、次のように書きます。

python -m research_analysis.cli \
  --input data/raw/sample.csv \
  --output outputs/tables/sample_result.csv \
  --threshold 5.0

これにより、解析の入口が固定されます。

Notebookでは、どのセルを実行するかを人間が判断していました。

CLIでは、コマンドを1回実行すれば、決められた順番で処理されます。

入力ファイルを読む
    ↓
イベントを選択する
    ↓
正規化する
    ↓
結果を保存する

「どのセルを先に実行するか」という状態依存を減らせるのが、CLI化の大きな利点です。

CLIの引数が増えすぎる問題

最初は、すべての条件をCLI引数として渡していました。

python -m research_analysis.cli \
  --input data/raw/sample.csv \
  --output outputs/tables/result.csv \
  --threshold 5.0 \
  --bin-width 0.1 \
  --min-energy 2.0 \
  --max-energy 10.0 \
  --background-file data/raw/background.csv \
  --exposure 50000 \
  --normalize \
  --method robust

しかし、解析条件が増えるとコマンドが長くなります。

さらに、後から結果を見たときに、どの条件で実行したのか分からなくなることもあります。

シェルの履歴に残っている可能性はありますが、解析条件の正式な記録としては不安定です。

そこで、解析条件をYAMLへ移しました。

YAMLで解析条件を管理する

例えば、次のような設定ファイルを作ります。

# configs/sample_a.yaml

input:
  path: data/raw/sample_a.csv

output:
  path: outputs/tables/sample_a_result.csv

analysis:
  threshold: 5.0
  normalize: true

histogram:
  bins: 50
  range:
    min: 0.0
    max: 1.0

YAMLを読み込むために、PyYAMLを使用します。

pip install pyyaml

設定ファイルを読み込む関数を作ります。

# src/research_analysis/config.py

from pathlib import Path
from typing import Any

import yaml


def load_config(path: Path) -> dict[str, Any]:
    if not path.exists():
        raise FileNotFoundError(f"Config file not found: {path}")

    with path.open("r", encoding="utf-8") as file:
        config = yaml.safe_load(file)

    if not isinstance(config, dict):
        raise ValueError("Config root must be a mapping.")

    return config

CLIは設定ファイルのパスだけを受け取る形に変更します。

# src/research_analysis/cli.py

import argparse
from pathlib import Path

from research_analysis.analysis import select_events
from research_analysis.config import load_config
from research_analysis.io import load_csv, save_csv


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Run research analysis from a YAML config."
    )

    parser.add_argument(
        "--config",
        type=Path,
        required=True,
        help="Path to the YAML configuration file.",
    )

    return parser


def main() -> None:
    args = build_parser().parse_args()
    config = load_config(args.config)

    input_path = Path(config["input"]["path"])
    output_path = Path(config["output"]["path"])
    threshold = float(config["analysis"]["threshold"])

    df = load_csv(input_path)
    result = select_events(df, threshold=threshold)
    save_csv(result, output_path)

    print(f"Config     : {args.config}")
    print(f"Input rows : {len(df)}")
    print(f"Output rows: {len(result)}")
    print(f"Saved to   : {output_path}")


if __name__ == "__main__":
    main()

実行コマンドは短くなります。

python -m research_analysis.cli \
  --config configs/sample_a.yaml

解析条件はYAMLに残ります。

configs/
├── sample_a.yaml
├── sample_b.yaml
└── sample_c.yaml

試料や観測データごとに設定ファイルを分ければ、同じ解析コードを異なる条件へ適用できます。

python -m research_analysis.cli --config configs/sample_a.yaml
python -m research_analysis.cli --config configs/sample_b.yaml
python -m research_analysis.cli --config configs/sample_c.yaml

コードをコピーしてanalysis_final.pyanalysis_final_v2.pyを増やす必要がなくなります。

YAMLに何でも書けばよいわけではない

YAMLへ移行した直後は、設定ファイルに多くの値を書きたくなります。

しかし、プログラム内部で固定すべき値までYAMLへ出すと、設定が複雑になります。

例えば、次のような設定です。

internal:
  temporary_column_name: signal_tmp
  csv_engine: c
  sort_algorithm: mergesort
  floating_point_epsilon: 1.0e-12

利用者が変更する必要のない値まで設定ファイルへ出すと、どれを変更してよいのか分かりにくくなります。

現在は、YAMLには主に次の値だけを書くようにしています。

  • 入力データ
  • 出力先
  • 解析対象の範囲
  • しきい値
  • 使用する解析手法
  • 図のビン数
  • 実験条件や観測条件

一方で、内部実装上の都合はPython側に残します。

「研究上の条件か、プログラム上の都合か」で分けると整理しやすくなりました。

YAMLの内容を検証する

YAMLは便利ですが、キーの書き間違いが実行時まで分からない問題があります。

analysis:
  thereshold: 5.0

thresholdtheresholdと書き間違えています。

単純な辞書アクセスでは、実行時にKeyErrorが発生します。

最低限、必要なキーを検証する関数を追加しました。

# src/research_analysis/config.py

from pathlib import Path
from typing import Any

import yaml


def load_config(path: Path) -> dict[str, Any]:
    if not path.exists():
        raise FileNotFoundError(f"Config file not found: {path}")

    with path.open("r", encoding="utf-8") as file:
        config = yaml.safe_load(file)

    if not isinstance(config, dict):
        raise ValueError("Config root must be a mapping.")

    validate_config(config)

    return config


def validate_config(config: dict[str, Any]) -> None:
    required_sections = {
        "input",
        "output",
        "analysis",
    }

    missing_sections = required_sections - config.keys()

    if missing_sections:
        missing = ", ".join(sorted(missing_sections))
        raise ValueError(f"Missing config sections: {missing}")

    if "path" not in config["input"]:
        raise ValueError("input.path is required.")

    if "path" not in config["output"]:
        raise ValueError("output.path is required.")

    if "threshold" not in config["analysis"]:
        raise ValueError("analysis.threshold is required.")

    threshold = config["analysis"]["threshold"]

    if not isinstance(threshold, int | float):
        raise ValueError("analysis.threshold must be numeric.")

規模が大きくなった場合は、Pydanticdataclassesを使って設定を構造化する方法もあります。

ただし、小さな研究コードでは、最初から複雑な設定管理ライブラリを導入しなくてもよいと思います。

まずは必要な項目だけを手動で検証する方法でも、かなり安全になります。

pytestで解析処理を検証する

CLIとYAMLを導入しても、コードを変更したときに解析結果が壊れる可能性は残ります。

例えば、データ選択処理を修正したとします。

selected = df[df["signal"] >= threshold]

以前は>だったのに、修正後は>=になっています。

この変更が意図したものなら問題ありません。

しかし、意図せず境界条件が変わっていた場合、結果に影響します。

そこでpytestを導入しました。

pip install pytest

最初に書いたテストは、非常に単純なものでした。

# tests/test_analysis.py

import pandas as pd

from research_analysis.analysis import select_events


def test_select_events_applies_threshold() -> None:
    df = pd.DataFrame(
        {
            "signal": [1.0, 5.0, 6.0, 10.0],
        }
    )

    result = select_events(df, threshold=5.0)

    assert result["signal"].tolist() == [6.0, 10.0]

このテストにより、しきい値と等しい値を含めないという仕様が固定されます。

正規化も確認します。

def test_select_events_normalizes_signal() -> None:
    df = pd.DataFrame(
        {
            "signal": [5.0, 10.0, 20.0],
        }
    )

    result = select_events(df, threshold=5.0)

    assert result["normalized"].tolist() == [0.5, 1.0]

空の結果を返す場合も確認します。

def test_select_events_handles_empty_result() -> None:
    df = pd.DataFrame(
        {
            "signal": [1.0, 2.0, 3.0],
        }
    )

    result = select_events(df, threshold=10.0)

    assert result.empty
    assert "normalized" in result.columns

テストは次のコマンドで実行できます。

pytest

実行結果は次のようになります。

============================= test session starts =============================
collected 3 items

tests/test_analysis.py ...                                         [100%]

============================== 3 passed in 0.42s ==============================

研究コードで何をテストするのか

研究コードにpytestを導入しようとすると、最初に困るのが「何をテストすればよいのか」です。

解析結果そのものが未知である場合、正解データを用意できないこともあります。

そのため、私は次のような部分からテストしています。

入力に対する出力の形

def test_output_has_required_columns() -> None:
    df = pd.DataFrame(
        {
            "signal": [1.0, 10.0],
        }
    )

    result = select_events(df, threshold=5.0)

    assert "signal" in result.columns
    assert "normalized" in result.columns

値の範囲

def test_normalized_values_are_between_zero_and_one() -> None:
    df = pd.DataFrame(
        {
            "signal": [6.0, 8.0, 10.0],
        }
    )

    result = select_events(df, threshold=5.0)

    assert result["normalized"].between(0.0, 1.0).all()

件数が増えていないこと

フィルタ処理後に、入力より行数が増えるのは通常おかしいと考えられます。

def test_selection_does_not_increase_row_count() -> None:
    df = pd.DataFrame(
        {
            "signal": [1.0, 2.0, 10.0],
        }
    )

    result = select_events(df, threshold=5.0)

    assert len(result) <= len(df)

不正な入力に対する挙動

import pandas as pd
import pytest

from research_analysis.analysis import select_events


def test_missing_signal_column_raises_error() -> None:
    df = pd.DataFrame(
        {
            "value": [1.0, 2.0, 3.0],
        }
    )

    with pytest.raises(KeyError):
        select_events(df, threshold=5.0)

既知の小さなデータでの結果

本番データ全体をテストに使う必要はありません。

数行の人工データを作り、手計算できる範囲で結果を確認します。

def test_known_small_dataset() -> None:
    df = pd.DataFrame(
        {
            "signal": [0.0, 2.0, 4.0, 8.0],
        }
    )

    result = select_events(df, threshold=3.0)

    assert result["signal"].tolist() == [4.0, 8.0]
    assert result["normalized"].tolist() == [0.5, 1.0]

研究上の仮説そのものをpytestで証明することはできません。

しかし、少なくとも次のことは確認できます。

  • 計算式が意図せず変わっていない
  • 単位変換を間違えていない
  • 境界条件が変わっていない
  • 必要な列が出力されている
  • NaNやゼロ除算が発生していない
  • ファイル名や設定値を正しく読めている

pytestは「解析が科学的に正しいこと」を保証するものではありません。

「以前確認したプログラム上の性質が、修正後も維持されていること」を確認する仕組みです。

YAMLのテストも書く

設定ファイルの読み込みもテストできます。

# tests/test_config.py

from pathlib import Path

import pytest
import yaml

from research_analysis.config import load_config


def test_load_config(tmp_path: Path) -> None:
    config_path = tmp_path / "config.yaml"

    config_data = {
        "input": {
            "path": "data/input.csv",
        },
        "output": {
            "path": "outputs/result.csv",
        },
        "analysis": {
            "threshold": 5.0,
        },
    }

    config_path.write_text(
        yaml.safe_dump(config_data),
        encoding="utf-8",
    )

    config = load_config(config_path)

    assert config["analysis"]["threshold"] == 5.0

必要な項目がない場合も確認します。

def test_missing_analysis_section_raises_error(
    tmp_path: Path,
) -> None:
    config_path = tmp_path / "config.yaml"

    config_data = {
        "input": {
            "path": "data/input.csv",
        },
        "output": {
            "path": "outputs/result.csv",
        },
    }

    config_path.write_text(
        yaml.safe_dump(config_data),
        encoding="utf-8",
    )

    with pytest.raises(
        ValueError,
        match="Missing config sections",
    ):
        load_config(config_path)

tmp_pathを使うと、テスト用の一時ファイルを安全に作れます。

本番の設定ファイルや出力ファイルを上書きする心配がありません。

CLIそのものもテストする

CLI全体をテストする場合は、subprocessを使って実際にコマンドを実行できます。

# tests/test_cli.py

import subprocess
import sys
from pathlib import Path

import pandas as pd
import yaml


def test_cli_creates_output_file(
    tmp_path: Path,
) -> None:
    input_path = tmp_path / "input.csv"
    output_path = tmp_path / "output.csv"
    config_path = tmp_path / "config.yaml"

    pd.DataFrame(
        {
            "signal": [1.0, 6.0, 10.0],
        }
    ).to_csv(input_path, index=False)

    config = {
        "input": {
            "path": str(input_path),
        },
        "output": {
            "path": str(output_path),
        },
        "analysis": {
            "threshold": 5.0,
        },
    }

    config_path.write_text(
        yaml.safe_dump(config),
        encoding="utf-8",
    )

    completed = subprocess.run(
        [
            sys.executable,
            "-m",
            "research_analysis.cli",
            "--config",
            str(config_path),
        ],
        capture_output=True,
        text=True,
        check=False,
    )

    assert completed.returncode == 0
    assert output_path.exists()

    result = pd.read_csv(output_path)

    assert result["signal"].tolist() == [6.0, 10.0]

関数単体のテストだけでなく、次の一連の流れを確認できます。

YAMLを読む
    ↓
入力CSVを読む
    ↓
解析する
    ↓
出力CSVを保存する

ただし、すべてをCLIテストにすると実行時間が長くなります。

そのため、基本的には関数単体のテストを多めにし、CLI全体のテストは代表的なケースだけにしています。

pyproject.tomlで実行環境をまとめる

パッケージとして扱いやすくするため、pyproject.tomlを用意しました。

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "research-analysis"
version = "0.1.0"
description = "Analysis tools for research data"
requires-python = ">=3.11"
dependencies = [
    "pandas>=2.0",
    "pyyaml>=6.0",
]

[project.optional-dependencies]
dev = [
    "pytest>=8.0",
]

[project.scripts]
research-analysis = "research_analysis.cli:main"

[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]

開発用としてインストールします。

pip install -e ".[dev]"

[project.scripts]を設定すると、次のように実行できます。

research-analysis --config configs/sample_a.yaml

毎回、次のように書く必要がなくなります。

python -m research_analysis.cli

研究コードで必ずパッケージ化が必要というわけではありません。

ただ、プロジェクトが長期化したり、複数の解析スクリプトを持つようになったりした場合は、src構成にしておくと整理しやすくなります。

解析条件も出力先へ保存する

YAMLで条件を管理しても、設定ファイルが後から書き換えられる可能性があります。

そこで、解析実行時に、使用した設定ファイルを出力ディレクトリへコピーするようにしました。

from pathlib import Path
import shutil


def copy_config(
    config_path: Path,
    output_dir: Path,
) -> None:
    output_dir.mkdir(parents=True, exist_ok=True)

    destination = output_dir / "config_used.yaml"
    shutil.copy2(config_path, destination)

出力を次のような構成にします。

outputs/
└── sample_a/
    ├── config_used.yaml
    ├── result.csv
    ├── summary.json
    └── figure.png

これにより、結果と解析条件を同じ場所に残せます。

さらに余裕があれば、次の情報も保存します。

  • 実行日時
  • Gitのコミットハッシュ
  • Pythonのバージョン
  • 使用したパッケージのバージョン
  • 入力ファイル名
  • 入力ファイルのハッシュ値

例えば、メタデータをJSONとして保存します。

import json
import platform
import subprocess
from datetime import datetime
from pathlib import Path


def get_git_commit() -> str | None:
    completed = subprocess.run(
        ["git", "rev-parse", "HEAD"],
        capture_output=True,
        text=True,
        check=False,
    )

    if completed.returncode != 0:
        return None

    return completed.stdout.strip()


def save_metadata(path: Path) -> None:
    metadata = {
        "executed_at": datetime.now().isoformat(),
        "python_version": platform.python_version(),
        "git_commit": get_git_commit(),
    }

    path.parent.mkdir(parents=True, exist_ok=True)

    path.write_text(
        json.dumps(
            metadata,
            indent=2,
            ensure_ascii=False,
        ),
        encoding="utf-8",
    )

研究コードでは、「現在動くか」だけでなく、「過去に何を実行したか」が重要になります。

NotebookからCLIへ移すと図の生成も安定した

以前は、Notebook上でグラフを表示し、必要なときだけ手作業で保存していました。

plt.hist(result["normalized"], bins=50)
plt.show()

この方法では、次のような問題が起きます。

  • 保存し忘れる
  • ファイル名が毎回違う
  • 図のサイズが変わる
  • ビン数を変更したことを忘れる
  • 発表資料に使った図の生成条件が分からない

そこで、図の生成も関数へ移しました。

# src/research_analysis/plotting.py

from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd


def save_histogram(
    df: pd.DataFrame,
    output_path: Path,
    bins: int,
) -> None:
    output_path.parent.mkdir(
        parents=True,
        exist_ok=True,
    )

    fig, ax = plt.subplots()

    ax.hist(
        df["normalized"],
        bins=bins,
    )

    ax.set_xlabel("Normalized signal")
    ax.set_ylabel("Counts")

    fig.tight_layout()
    fig.savefig(
        output_path,
        dpi=200,
    )

    plt.close(fig)

YAML側に図の設定を書きます。

histogram:
  bins: 50
  output: outputs/figures/sample_a_histogram.png

CLIを実行すると、表と図が同時に生成されます。

outputs/
├── figures/
│   └── sample_a_histogram.png
└── tables/
    └── sample_a_result.csv

発表直前にNotebookを開き、どのセルを実行すれば図が再生成できるのか探す作業が減りました。

移行時に失敗したこと

最初から完璧な構成を目指した

最初は、設定クラス、ロギング、例外クラス、プラグイン構造などを一度に導入しようとしました。

しかし、研究の本体はソフトウェア設計ではありません。

構成を整えることに時間を使いすぎると、解析そのものが進まなくなります。

結局、次の順番で少しずつ移行するのが最も楽でした。

  1. Notebookの処理を関数にする
  2. 関数のテストを書く
  3. CLIから関数を呼ぶ
  4. パラメータをYAMLへ移す
  5. 出力と設定を一緒に保存する
  6. 必要になった部分だけ改善する

Notebookを完全に禁止しようとした

Notebookを使うとコードが汚くなると考え、一時期はすべてをPythonスクリプトで書こうとしました。

しかし、新しい解析方法を試す段階ではNotebookの方が明らかに便利です。

問題はNotebookを使うことではなく、Notebookだけを正式な解析パイプラインにすることでした。

現在は、探索段階ではNotebookを積極的に使い、処理が固まったら関数へ移しています。

pytestで本番データを丸ごと処理した

最初は、本番データを使って解析全体をテストしていました。

その結果、テストに時間がかかり、気軽に実行できなくなりました。

現在は、テスト用の小さな人工データを使っています。

本番データ
    → 実際の解析に使う

人工的な小規模データ
    → pytestに使う

高速なテストは何度でも実行できます。

研究コードでは、非常に詳細なテストを少数書くより、数秒で終わる基本テストを継続して実行する方が役立つ場面も多いと感じています。

YAMLをコピーして似た設定が増えた

YAML化すると、今度は似た設定ファイルが増えていきます。

sample_a.yaml
sample_a_new.yaml
sample_a_final.yaml
sample_a_final2.yaml
sample_a_really_final.yaml

これでは、Pythonファイルをコピーしていた頃とあまり変わりません。

現在は、設定ファイル名に解析対象と目的を含めています。

configs/
├── sample_a_baseline.yaml
├── sample_a_threshold_scan.yaml
├── sample_b_baseline.yaml
└── sample_b_high_energy.yaml

また、一時的な実行条件はGitへ残さず、正式な結果に使った設定だけを管理するようにしています。

移行後に変わったこと

結果を再生成しやすくなった

以前は、図を再生成するためにNotebookを開き、セルを順番に確認する必要がありました。

現在は、次のコマンドで再生成できます。

research-analysis \
  --config configs/sample_a_baseline.yaml

パラメータ変更が差分として見えるようになった

YAMLをGitで管理すると、解析条件の変更が差分で確認できます。

 analysis:
-  threshold: 5.0
+  threshold: 4.5

Notebookのセル内に埋め込まれていた頃より、何を変更したのか分かりやすくなりました。

コード修正への不安が減った

研究コードでは、1か所を修正したことで、別の解析結果が変わることがあります。

pytestがあれば、少なくとも既知のケースについては、意図しない変更を検出できます。

pytest
12 passed

この表示だけで科学的な正しさが保証されるわけではありません。

それでも、「基本的な計算処理は以前と同じ」という確認があるだけで、コードを修正しやすくなりました。

他のデータへ適用しやすくなった

以前はNotebookをコピーしていました。

analysis_sample_a.ipynb
analysis_sample_b.ipynb
analysis_sample_c.ipynb

現在はYAMLを増やします。

configs/
├── sample_a.yaml
├── sample_b.yaml
└── sample_c.yaml

解析ロジックは共通なので、修正箇所が1か所で済みます。

AIにコードを書かせやすくなった

これは移行後に感じた副次的な利点です。

Notebook全体をAIへ渡して修正させると、どのセルが正式な処理なのか分かりにくくなります。

一方、処理が関数・CLI・設定・テストに分かれていると、依頼を具体化できます。

analysis.pyのselect_events関数を変更する。
既存のpytestをすべて通す。
境界条件のテストを1件追加する。
CLIの引数とYAML形式は変更しない。

このように、変更対象と制約を明確にできます。

特にpytestがあると、「それらしいコードを書かせる」のではなく、「テストが通るところまで修正させる」という進め方ができます。

現在の使い分け

現在は、次のように使い分けています。

役割 使用するもの
データの確認 Jupyter Notebook
新しい処理の試作 Jupyter Notebook
正式な解析ロジック Python関数
解析の実行 CLI
解析条件 YAML
結果の確認 Notebook、CSV、画像
最低限の動作保証 pytest
変更履歴 Git

NotebookとCLIは競合するものではありません。

目的が異なります。

Notebook
    人間が考えながら操作する

CLI
    同じ手順を機械的に再実行する

研究では両方必要でした。

最小構成から始めるなら

これから移行する場合、最初から記事内のすべてを導入する必要はありません。

まずは次の構成だけでも十分です。

project/
├── config.yaml
├── analysis.py
├── run.py
└── test_analysis.py

analysis.pyに解析関数を書きます。

def calculate_mean(values: list[float]) -> float:
    if not values:
        raise ValueError("values must not be empty")

    return sum(values) / len(values)

run.pyから呼び出します。

import yaml

from analysis import calculate_mean


with open("config.yaml", encoding="utf-8") as file:
    config = yaml.safe_load(file)

values = config["values"]
result = calculate_mean(values)

print(result)

config.yamlに条件を書きます。

values:
  - 1.0
  - 2.0
  - 3.0

test_analysis.pyで確認します。

import pytest

from analysis import calculate_mean


def test_calculate_mean() -> None:
    assert calculate_mean([1.0, 2.0, 3.0]) == 2.0


def test_empty_values_raise_error() -> None:
    with pytest.raises(ValueError):
        calculate_mean([])

実行します。

python run.py
pytest

これだけでも、Notebookにすべてを書く状態から一歩進めます。

まとめ

研究コードをNotebookからCLI・YAML・pytest構成へ移したことで、次の点が改善しました。

  • 実行順序が固定された
  • 解析条件をファイルとして残せるようになった
  • 同じ解析を別のデータへ適用しやすくなった
  • 図や表を再生成しやすくなった
  • コード変更による意図しない影響を検出しやすくなった
  • AIや共同研究者へ作業を依頼しやすくなった
  • 過去の結果を再現できる可能性が高くなった

一方で、Notebookを使わなくなったわけではありません。

Notebookは、探索、可視化、確認、考察に非常に便利です。

重要だったのは、Notebookをやめることではなく、Notebookにすべての責任を持たせないことでした。

探索はNotebook
実行はCLI
条件はYAML
確認はpytest

この分担にしてから、研究コードを修正する心理的な負担がかなり減りました。

研究コードは、最初から製品レベルの設計にする必要はないと思います。

ただし、解析が長期化し、「この結果を来月もう一度出せるだろうか」と不安になり始めた時点で、Notebookの外へ処理を移す価値は十分にあります。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?