ローカルUbuntu Serverで実験管理環境を作る:MLflow / DuckDB / Hydra / Optuna / Prefect / DVCの使い分けについて
はじめに
パラメータを少しずつ変えながら、同じような計算を何度も回すことがあります。
また、同時並行で複数業務をこなしていると、訳がわからなくなりますよね。
機械学習でも、数値計算でも、シミュレーションでも、最初のうちはCSVやフォルダ名で何とか管理できます。ところが、ケース数が増えてくると、だんだん次のような状態になります。
- どのパラメータで実行した結果なのか分からない
- 一番よかったケースを後から探すのが面倒
- 結果CSVや図が増えすぎて整理できない
- 入力データを少し修正した後、前の結果と比較しづらい
- 再実行したいが、当時の条件が残っていない
このあたりをきちんと管理するために、ローカルのUbuntu Server上で、軽めの実験管理環境を作ることを考えます。
ここでは、次のツールを対象にします。
実験管理環境ツール
- MLflow
- DuckDB
- Hydra
- Optuna
- Prefect
- DVC
全部を最初から入れて使いこなす必要はありません。むしろ、最初は小さく始めて、必要になった段階で追加していく方がよいです。
全体の考え方
大まかには、次のように役割を分けます。
| やりたいこと | 使うツール | 役割 |
|---|---|---|
| パラメータを変えて何度も計算する | MLflow | 実験条件と結果の記録 |
| 計算結果を一覧比較したい | MLflow | runごとの比較、指標の確認 |
| 大量の時系列・表形式データを保存したい | DuckDB | 結果データベース |
| パラメータの全組み合わせを自動実行したい | Hydra | 設定管理、一括実行 |
| 最適なパラメータを自動探索したい | Optuna | パラメータ最適化 |
| 複数ステップの処理フローを管理したい | Prefect | ワークフロー管理 |
| 入力データや成果物のバージョン管理もしたい | DVC | データ・成果物の版管理 |
イメージとしては、MLflowを中心に置きます。
Optuna、Prefect、DVCは、必要になってから追加する位置づけです。
まずはMLflow + DuckDB + YAMLで十分
最初に作るなら、次の3つでよいと思います。
MLflow : 実験管理
DuckDB : 大量の結果保存
YAML : 設定ファイル管理
MLflowには、各実行ケースの条件と代表的な結果を保存します。
たとえば、次のようなものです。
parameters:
case_name
model_type
dt
learning_rate
max_iter
seed
metrics:
rmse
mae
score
runtime_sec
status
artifacts:
config.yaml
result_summary.csv
graph.png
log.txt
一方で、詳細な時系列データや大きな計算結果はDuckDBに入れます。
MLflowに何でも入れると重くなります。MLflowは「実験台帳」、DuckDBは「結果データベース」と分けて考えると、後で扱いやすいです。
Ubuntu Server側の準備
ここではUbuntu Server 22.04 / 24.04あたりを想定します。
この準備に関しては、それぞれの環境があると思いますので、皆さんの環境に合わせてください。
まず、Pythonと基本ツールを入れます。
sudo apt update
sudo apt install -y python3 python3-venv python3-pip git build-essential
作業用ディレクトリを作ります。
mkdir -p ~/exp_management_local
cd ~/exp_management_local
Python仮想環境を作ります。
python3 -m venv .venv
source .venv/bin/activate
python -m pip install --upgrade pip
必要なライブラリを入れます。
pip install mlflow duckdb hydra-core optuna prefect dvc pandas pyarrow matplotlib pyyaml
バージョンを確認します。
python --version
mlflow --version
dvc --version
prefect version
フォルダ構成
最初は、これくらいの構成で十分です。
exp_management_local/
├─ configs/
│ └─ base.yaml
│
├─ data/
│ ├─ raw/
│ └─ processed/
│
├─ src/
│ ├─ run_experiment.py
│ └─ utils.py
│
├─ outputs/
│ ├─ figures/
│ ├─ tables/
│ └─ duckdb/
│ └─ results.duckdb
│
├─ mlartifacts/
├─ mlflow.db
├─ requirements.txt
└─ README.md
ポイントは、MLflowのメタデータと成果物をローカルに置いていることです。
mlflow.db : MLflowの実験情報
mlartifacts/ : 図、モデル、CSVなどの成果物
outputs/ : 通常の出力ファイル
DuckDB : 詳細な結果保存
MLflowサーバーを起動する
ローカルサーバーとしてMLflowを起動します。
cd ~/exp_management_local
source .venv/bin/activate
mlflow server \
--backend-store-uri sqlite:///mlflow.db \
--default-artifact-root ./mlartifacts \
--host 0.0.0.0 \
--port 5000
同じサーバー内から見る場合は、次で開けます。
http://127.0.0.1:5000
別PCから見る場合は、Ubuntu ServerのIPアドレスを使います。
http://<Ubuntu_ServerのIPアドレス>:5000
ファイアウォールを使っている場合は、必要に応じてポートを開けます。
sudo ufw allow 5000/tcp
ただし、社内LANや自宅LANで使う場合でも、外部に公開しないように注意します。
YAMLで計算条件を書く
設定ファイルはYAMLにしておくと、後で見返しやすいです。
configs/base.yaml の例です。
project:
name: sample_experiment
experiment_name: local_experiment_management
data:
input_path: data/raw/input.csv
output_db: outputs/duckdb/results.duckdb
params:
case_name: case_001
alpha: 0.5
beta: 1.2
max_iter: 100
seed: 42
mlflow:
tracking_uri: http://127.0.0.1:5000
このようにしておくと、「どの条件で計算したか」をファイルとして残せます。
MLflowとDuckDBに記録するサンプル
簡単なサンプルです。
src/run_experiment.py を作ります。
"""MLflowとDuckDBを使ったローカル実験管理の最小サンプル。
このスクリプトでは、説明用の簡単な反復計算を実行し、
実験条件と代表指標をMLflowへ、詳細な計算結果をDuckDBへ保存します。
実際の業務では、dummy_calculation() の中身を、
機械学習、数値シミュレーション、統計処理などの本処理に置き換えます。
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Any
import duckdb
import matplotlib.pyplot as plt
import mlflow
import pandas as pd
import yaml
CONFIG_PATH = Path("configs/base.yaml")
FIGURE_DIR = Path("outputs/figures")
DUCKDB_DIR = Path("outputs/duckdb")
def load_config(path: Path) -> dict[str, Any]:
"""YAML設定ファイルを読み込む。
Parameters
----------
path : Path
読み込むYAMLファイルのパス。
この記事の例では ``configs/base.yaml`` を指定する。
Returns
-------
dict[str, Any]
YAMLの内容をPythonの辞書として返す。
``project``、``data``、``params``、``mlflow`` などのキーを想定する。
Raises
------
FileNotFoundError
指定したYAMLファイルが存在しない場合に発生する。
yaml.YAMLError
YAMLの書式が壊れている場合に発生する。
"""
with path.open("r", encoding="utf-8") as f:
return yaml.safe_load(f)
def prepare_output_dirs() -> None:
"""出力用ディレクトリを作成する。
``exist_ok=True`` にしているため、すでにディレクトリがある場合でも
エラーにせず、そのまま処理を続ける。
"""
FIGURE_DIR.mkdir(parents=True, exist_ok=True)
DUCKDB_DIR.mkdir(parents=True, exist_ok=True)
def dummy_calculation(alpha: float, beta: float, max_iter: int) -> pd.DataFrame:
"""説明用の簡単な反復計算を実行する。
計算式は以下の通り。
``value_next = alpha * value_current + beta``
ここでは、パラメータを変えて何度も同じ計算を実行する状況を
わかりやすく示すため、あえて単純な式にしている。
Parameters
----------
alpha : float
前ステップの値に掛ける係数。
beta : float
各ステップで加算する値。
max_iter : int
反復回数。
Returns
-------
pd.DataFrame
``step`` と ``value`` を持つ計算結果テーブル。
DuckDBへ保存しやすいようにDataFrameで返す。
"""
rows: list[dict[str, float | int]] = []
value = 1.0
for step in range(max_iter):
# ここが計算本体。
# 実際のモデルでは、この部分がシミュレーションや推論処理になる。
value = alpha * value + beta
# 後で時系列として確認できるように、各ステップの値を保存する。
rows.append({"step": step, "value": value})
return pd.DataFrame(rows)
def log_parameters_to_mlflow(params: dict[str, Any]) -> None:
"""実験パラメータをMLflowへ記録する。
Parameters
----------
params : dict[str, Any]
YAMLの ``params`` セクション。
Notes
-----
MLflowの ``log_param`` は、後からrunを比較するときの軸になる。
そのため、計算結果に影響する条件はできるだけ記録しておく。
"""
mlflow.log_param("case_name", params["case_name"])
mlflow.log_param("alpha", params["alpha"])
mlflow.log_param("beta", params["beta"])
mlflow.log_param("max_iter", params["max_iter"])
mlflow.log_param("seed", params["seed"])
def save_result_plot(df: pd.DataFrame, case_name: str) -> Path:
"""計算結果の折れ線グラフを作成してPNGとして保存する。
Parameters
----------
df : pd.DataFrame
``step`` と ``value`` を含む計算結果。
case_name : str
図のタイトルに使うケース名。
Returns
-------
Path
保存したPNGファイルのパス。
"""
fig_path = FIGURE_DIR / "result_curve.png"
plt.figure(figsize=(8, 4))
plt.plot(df["step"], df["value"])
plt.xlabel("step")
plt.ylabel("value")
plt.title(case_name)
plt.tight_layout()
plt.savefig(fig_path, dpi=150)
plt.close()
return fig_path
def save_results_to_duckdb(
df: pd.DataFrame,
db_path: str,
run_id: str,
case_name: str,
) -> None:
"""詳細な計算結果をDuckDBへ保存する。
Parameters
----------
df : pd.DataFrame
計算結果のDataFrame。
db_path : str
保存先DuckDBファイルのパス。
run_id : str
MLflowが発行したrun ID。
DuckDB側にも保存しておくと、MLflowのrunと詳細結果を対応づけられる。
case_name : str
実験ケース名。
Notes
-----
MLflowには代表指標を保存し、DuckDBには詳細テーブルを保存する。
この分担にしておくと、実験数や結果データが増えても管理しやすい。
"""
result_df = df.copy()
result_df["run_id"] = run_id
result_df["case_name"] = case_name
con = duckdb.connect(db_path)
try:
# 初回実行時だけテーブルを作成する。
# 2回目以降は既存テーブルへ追記する。
con.execute(
"""
CREATE TABLE IF NOT EXISTS results (
run_id TEXT,
case_name TEXT,
step INTEGER,
value DOUBLE
)
"""
)
# DataFrameをDuckDBに一時ビューとして登録する。
# その後、SQLで必要な列だけをresultsテーブルへ追加する。
con.register("df_result", result_df)
con.execute(
"""
INSERT INTO results
SELECT run_id, case_name, step, value
FROM df_result
"""
)
finally:
con.close()
def main() -> None:
"""実験の一連の流れを実行する。
実行内容は以下の通り。
1. YAML設定ファイルを読み込む
2. 出力ディレクトリを準備する
3. MLflowの接続先と実験名を設定する
4. MLflow runを開始する
5. パラメータをMLflowへ記録する
6. 計算を実行する
7. 代表指標をMLflowへ記録する
8. 図と設定ファイルをMLflow artifactとして保存する
9. 詳細結果をDuckDBへ保存する
"""
cfg = load_config(CONFIG_PATH)
prepare_output_dirs()
# MLflow Tracking Serverの接続先を設定する。
# ローカルUbuntu Serverで起動したMLflowサーバーを想定している。
mlflow.set_tracking_uri(cfg["mlflow"]["tracking_uri"])
mlflow.set_experiment(cfg["project"]["experiment_name"])
params = cfg["params"]
start_time = time.time()
# 1回の実行を1つのrunとして記録する。
# run_nameには、後から一覧で見やすいようにケース名を入れる。
with mlflow.start_run(run_name=params["case_name"]) as run:
run_id = run.info.run_id
log_parameters_to_mlflow(params)
df = dummy_calculation(
alpha=float(params["alpha"]),
beta=float(params["beta"]),
max_iter=int(params["max_iter"]),
)
# 代表的な評価指標を計算する。
# ここでは説明用に、最終値と実行時間だけを記録する。
final_value = float(df["value"].iloc[-1])
runtime_sec = time.time() - start_time
mlflow.log_metric("final_value", final_value)
mlflow.log_metric("runtime_sec", runtime_sec)
fig_path = save_result_plot(df, params["case_name"])
# 設定ファイルと図をartifactとして保存する。
# artifactに入れておくと、MLflow UIから後で確認できる。
mlflow.log_artifact(str(CONFIG_PATH), artifact_path="config")
mlflow.log_artifact(str(fig_path), artifact_path="figures")
save_results_to_duckdb(
df=df,
db_path=cfg["data"]["output_db"],
run_id=run_id,
case_name=params["case_name"],
)
print(f"run_id = {run_id}")
print(f"final_value = {final_value}")
print(f"runtime_sec = {runtime_sec:.3f}")
if __name__ == "__main__":
main()
実行します。
python src/run_experiment.py
MLflow UIを見ると、パラメータや評価指標が記録されています。
DuckDBの中身を確認する
Pythonから確認する場合です。
"""DuckDBに保存した計算結果を確認する簡単なスクリプト。"""
from pathlib import Path
import duckdb
DB_PATH = Path("outputs/duckdb/results.duckdb")
def show_results(limit: int = 10) -> None:
"""DuckDBのresultsテーブルから先頭行を表示する。
Parameters
----------
limit : int, optional
表示する行数。デフォルトは10行。
"""
con = duckdb.connect(str(DB_PATH))
try:
# fetchdf()を使うと、SQLの結果をpandas.DataFrameとして取得できる。
result_df = con.execute(
"SELECT * FROM results LIMIT ?",
[limit],
).fetchdf()
print(result_df)
finally:
con.close()
if __name__ == "__main__":
show_results(limit=10)
SQLで集計できるので、ケース数が増えたときに便利です。
SELECT
case_name,
MAX(value) AS max_value,
MIN(value) AS min_value
FROM results
GROUP BY case_name;
Hydraを使う場面
ケース数が少ないうちは、YAMLを手で書き換えても問題ありません。
ただ、次のような組み合わせを試したくなると、手作業では面倒です。
alpha = 0.3, 0.5, 0.7
beta = 1.0, 1.2, 1.5
max_iter = 100, 200
このような全組み合わせ実行にはHydraが向いています。
Hydraを使うと、コマンドラインから設定値を上書きできます。
python src/run_experiment.py params.alpha=0.7 params.beta=1.5
さらに、multi-runを使うと複数条件をまとめて流せます。
python src/run_experiment.py -m params.alpha=0.3,0.5,0.7 params.beta=1.0,1.2,1.5
実験数が増えてきた段階で導入すると効果があります。
Optunaを使う場面
Hydraは、指定した組み合わせを全部試すためのものです。
一方で、Optunaは「どの値が良さそうか」を自動で探すために使います。
たとえば、ある評価指標を最小化したい場合は、次のような考え方になります。
"""Optunaで説明用のパラメータ探索を行うサンプル。"""
import optuna
def objective(trial: optuna.Trial) -> float:
"""Optunaが最小化する目的関数。
Parameters
----------
trial : optuna.Trial
Optunaが1回の試行ごとに渡してくるTrialオブジェクト。
このオブジェクトを使って探索するパラメータを定義する。
Returns
-------
float
最小化したいスコア。
この例では、alpha=0.6、beta=1.3に近いほど小さくなる値を返す。
Notes
-----
実際の用途では、この関数の中でモデル計算やシミュレーションを実行し、
RMSE、誤差、計算コストなどの評価値を返す。
"""
# suggest_float()で、探索する連続値パラメータの範囲を指定する。
alpha = trial.suggest_float("alpha", 0.1, 0.9)
beta = trial.suggest_float("beta", 0.5, 2.0)
# ここでは説明用に単純な二次関数を評価指標にしている。
# scoreが小さいほど良い、という想定。
score = (alpha - 0.6) ** 2 + (beta - 1.3) ** 2
return score
def main() -> None:
"""Optunaのstudyを作成し、50回の探索を実行する。"""
study = optuna.create_study(direction="minimize")
study.optimize(objective, n_trials=50)
print("best_params:", study.best_params)
print("best_value:", study.best_value)
if __name__ == "__main__":
main()
機械学習のハイパーパラメータ探索だけでなく、シミュレーションの係数調整にも使えます。
Prefectを使う場面
処理が1本のスクリプトで済むうちは、Prefectは不要です。
ただし、次のようにステップが増えてくると、ワークフローとして管理したくなります。
1. データ取得
2. 前処理
3. 計算実行
4. 結果集計
5. 図作成
6. MLflowへ記録
7. DuckDBへ保存
8. レポート作成
このような場合にPrefectを使います。
簡単な例です。
"""Prefectで複数ステップの処理をワークフロー化する最小例。"""
from prefect import flow, task
@task
def preprocess() -> None:
"""前処理ステップ。
実際の処理では、入力データの取得、欠損補完、単位変換、
学習用データや計算用データの作成などを行う。
"""
print("preprocess")
@task
def run_model() -> None:
"""計算または学習を実行するステップ。
数値シミュレーション、機械学習モデルの学習、推論処理など、
プロジェクトの中心になる処理をここに書く。
"""
print("run model")
@task
def summarize() -> None:
"""結果を集計するステップ。
評価指標の計算、図の作成、MLflowへの記録、
DuckDBへの保存などをここで行う。
"""
print("summarize")
@flow(name="sample-local-workflow")
def main_flow() -> None:
"""3つのtaskを順番に実行するワークフロー。"""
preprocess()
run_model()
summarize()
if __name__ == "__main__":
main_flow()
最初から入れるというより、定期実行、再実行、失敗時のリトライなどが必要になった段階で使うのがよいです。
DVCを使う場面
Gitだけでは、大きな入力データやモデルファイルの管理がつらくなります。
たとえば、次のようなものです。
data/raw/input.csv
outputs/model/model.pkl
outputs/predictions/result.parquet
このようなファイルをバージョン管理したい場合にDVCを使います。
まずGitとDVCを初期化します。
git init
dvc init
入力データをDVC管理にします。
dvc add data/raw
Gitには、DVCが作ったメタファイルを登録します。
git add data/raw.dvc .gitignore
git commit -m "add raw data with dvc"
ローカルの別ディレクトリをDVC remoteにすることもできます。
mkdir -p ~/dvc_storage
dvc remote add -d localstore ~/dvc_storage
データを保存します。
dvc push
DVCを使うと、コードだけでなく、入力データや成果物も含めて「どの状態で計算したか」を残しやすくなります。
導入順のおすすめ
個人的には、次の順番が扱いやすいです。
| 段階 | 入れるもの | 目的 |
|---|---|---|
| 1 | YAML + MLflow + DuckDB | 実験条件と結果を残す |
| 2 | Hydra | ケースをまとめて実行する |
| 3 | Optuna | パラメータを自動探索する |
| 4 | DVC | 入力データと成果物を版管理する |
| 5 | Prefect | 処理全体をワークフロー化する |
最初から全部入れると、環境構築だけで疲れます。
まずは、MLflowで実験を残す。詳細結果はDuckDBに入れる。設定はYAMLで残す。これだけでも、だいぶ管理しやすくなります。
使い分けのまとめ
最後に、使い分け案をもう一度まとめます。
あくまでも、個人の見解です。
実験管理環境の使い分け案
-
MLflow
実験条件、評価指標、図、モデル、ログを管理する -
DuckDB
大量の表形式データ、時系列データ、計算結果を保存する -
Hydra
YAML設定を切り替えながら複数ケースを実行する -
Optuna
評価指標が良くなるパラメータを自動で探す -
Prefect
複数ステップの処理をワークフローとして管理する -
DVC
入力データ、加工データ、成果物をバージョン管理する
ローカルのUbuntu Serverで始めるなら、まずは次の構成で十分だと考えています。
Ubuntu Server
├─ Python venv
├─ MLflow server
├─ SQLite backend
├─ local artifact store
├─ DuckDB
└─ YAML設定ファイル
ここから始めて、必要になったらHydra、Optuna、DVC、Prefectを足していくのが現実的ですね。

