研究室の実験管理を透明化する
はじめに
研究室で機械学習やLLMの実験を進めていると、こんな課題に直面することはありませんか?
- 「誰がどんな実験をしているのか分からない」
- 「過去の実験結果がどこにあるか探すのに時間がかかる」
- 「同じような実験を別の人が重複して行ってしまう」
- 「実験の進捗状況が見えず、プロジェクト全体の把握が難しい」
本記事では、これらの課題を解決するために、研究室メンバー全員で実験情報を共有できる実験管理システムを構築した経験を共有します。
FastAPI + React + PostgreSQL の構成で、Docker化により「誰でも同じ環境」で動かせるシステムを目指しました。
なぜ実験管理システムが必要なのか
研究室における実験管理の課題
研究開発の現場では、日々多くの実験が並行して進められます。特に機械学習やLLMの分野では:
- 実験の数が膨大: ハイパーパラメータの組み合わせだけで数十〜数百の実験が生まれる
- 担当者が複数: 複数人が異なるアプローチで実験を進める
- 結果の散在: Jupyter Notebook、スプレッドシート、Slackメッセージなど、情報が分散
- 再現性の確保: 数ヶ月後に「あの実験はどうやったんだっけ?」となりがち
実験管理システムが提供する価値
実験管理システムを導入することで、以下のような透明性向上が期待できます:
- 可視化: 全実験の一覧を俯瞰し、プロジェクトの進捗を把握
- 検索性: タグや担当者でフィルタリングし、関連実験をすぐに発見
- 共有: 実験結果へのリンクを一元管理し、知見を共有
- 再現性: 実験条件やリポジトリURLを記録し、再実行を容易に
システムアーキテクチャ概要
今回構築したシステムは、モダンなWeb技術スタックを採用した三層構成です。
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ フロントエンド │ <──> │ バックエンド │ <──> │ データベース │
│ React + Vite │ │ FastAPI │ │ PostgreSQL │
│ (TypeScript) │ │ (Python + uv) │ │ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
↑ ↑ ↑
└─────────────────────────┴─────────────────────────┘
Docker Compose で一括管理
技術スタック選定の理由
バックエンド: FastAPI
- 高速なAPI開発が可能
- 自動的なAPI ドキュメント生成 (Swagger UI)
- Pythonエコシステムとの親和性が高く、研究者が慣れている
フロントエンド: React + Vite
- コンポーネントベースで拡張しやすい
- Viteによる高速な開発体験
- TypeScriptで型安全性を確保
データベース: PostgreSQL
- 信頼性の高いRDBMS
- JSON型 (JSONB) でタグや拡張データを柔軟に扱える
- 研究室規模であれば十分なパフォーマンス
環境管理: Docker Compose
- 誰でも同じ環境を再現可能
- 開発環境と本番環境の差異を最小化
- チーム全体で統一した開発体験
データモデル設計
実験管理の中心となるのが experiments テーブルです。
experiments テーブルの構造
| カラム名 | 型 | 説明 |
|---|---|---|
| id | SERIAL | 主キー (自動採番) |
| slug | VARCHAR | URL用の文字列 (タイトルから自動生成、ユニーク) |
| title | VARCHAR | 実験タイトル |
| description | TEXT | 実験の概要・目的 |
| owner | VARCHAR | 担当者名 |
| status | ENUM | planned / running / review / completed / archived |
| tags | JSONB | タグ一覧 (配列) |
| repo_url | VARCHAR | GitHubリポジトリURL |
| results_url | VARCHAR | 実験結果・ダッシュボードURL |
| external_links | JSONB | 参考リンク集 |
| notes | TEXT | 補足メモ |
| created_at | TIMESTAMP | 作成日時 |
| updated_at | TIMESTAMP | 更新日時 |
設計のポイント
slug フィールド
タイトルから自動生成される URL フレンドリーな文字列です。例えば「BERT Fine-tuning Experiment」というタイトルから「bert-fine-tuning-experiment」というslugが生成されます。これにより、/experiments/bert-fine-tuning-experiment のような分かりやすいURLでアクセスできます。
status による状態管理
実験の進行状況を5段階で管理します:
-
planned: 計画中 -
running: 実行中 -
review: レビュー中 -
completed: 完了 -
archived: アーカイブ済み
JSONB 型の活用
タグや外部リンクをJSONB型で保存することで、構造を柔軟に拡張できます。PostgreSQLのJSONBはインデックスも張れるため、検索パフォーマンスも良好です。
バックエンド実装
ディレクトリ構造
backend/
├── app/
│ ├── main.py # FastAPIアプリのエントリーポイント
│ ├── config.py # 環境変数の設定
│ ├── database.py # DB接続設定
│ ├── models.py # SQLModelによるデータモデル定義
│ └── api/
│ └── v1/
│ └── experiments.py # 実験管理のAPI
├── tests/ # テストコード
├── pyproject.toml # uvによる依存管理
└── .env.example # 環境変数のサンプル
uv による依存管理
本プロジェクトでは、Pythonのパッケージ管理にuvを採用しました。uvはRustで書かれた高速なパッケージマネージャで、pipやpoetryの代替として注目されています。
[project]
name = "experiment-management-backend"
version = "0.1.0"
dependencies = [
"fastapi",
"sqlmodel",
"psycopg2-binary",
"python-dotenv",
"uvicorn"
]
[project.optional-dependencies]
dev = [
"pytest",
"httpx",
"ruff"
]
依存関係のインストールは以下のコマンドで実行できます:
uv sync --extra dev
SQLModel によるモデル定義
SQLModelは、Pydantic と SQLAlchemy を統合したライブラリで、型ヒントを使って直感的にモデルを定義できます。
from sqlmodel import SQLModel, Field
from typing import Optional, List
from datetime import datetime
from enum import Enum
class ExperimentStatus(str, Enum):
PLANNED = "planned"
RUNNING = "running"
REVIEW = "review"
COMPLETED = "completed"
ARCHIVED = "archived"
class Experiment(SQLModel, table=True):
__tablename__ = "experiments"
id: Optional[int] = Field(default=None, primary_key=True)
slug: str = Field(unique=True, index=True)
title: str
description: Optional[str] = None
owner: str
status: ExperimentStatus = Field(default=ExperimentStatus.PLANNED)
tags: List[str] = Field(default_factory=list, sa_column=Column(JSONB))
repo_url: Optional[str] = None
results_url: Optional[str] = None
external_links: Optional[dict] = Field(default_factory=dict, sa_column=Column(JSONB))
notes: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
API エンドポイント設計
RESTful な設計で、実験の CRUD 操作を提供します。すべてのエンドポイントは /api/v1/experiments 配下に統一しています。
実験の登録
@router.post("/", response_model=Experiment)
async def create_experiment(
experiment: ExperimentCreate,
db: Session = Depends(get_db)
):
# タイトルから slug を自動生成
slug = generate_slug(experiment.title)
# 重複チェック
existing = db.query(Experiment).filter(Experiment.slug == slug).first()
if existing:
# 重複する場合は連番を付与
slug = f"{slug}-{uuid.uuid4().hex[:8]}"
db_experiment = Experiment(**experiment.dict(), slug=slug)
db.add(db_experiment)
db.commit()
db.refresh(db_experiment)
return db_experiment
実験の一覧取得 (フィルタ機能付き)
@router.get("/", response_model=List[Experiment])
async def list_experiments(
owner: Optional[str] = None,
status: Optional[ExperimentStatus] = None,
search: Optional[str] = None,
tag: Optional[str] = None,
db: Session = Depends(get_db)
):
query = db.query(Experiment)
if owner:
query = query.filter(Experiment.owner == owner)
if status:
query = query.filter(Experiment.status == status)
if search:
query = query.filter(
or_(
Experiment.title.contains(search),
Experiment.description.contains(search)
)
)
if tag:
# JSONB の配列に対する検索
query = query.filter(Experiment.tags.contains([tag]))
return query.order_by(Experiment.created_at.desc()).all()
実験の更新
@router.put("/{experiment_id}", response_model=Experiment)
async def update_experiment(
experiment_id: int,
experiment: ExperimentUpdate,
db: Session = Depends(get_db)
):
db_experiment = db.query(Experiment).filter(Experiment.id == experiment_id).first()
if not db_experiment:
raise HTTPException(status_code=404, detail="Experiment not found")
# タイトルが変更された場合は slug を再生成
if experiment.title and experiment.title != db_experiment.title:
db_experiment.slug = generate_slug(experiment.title)
for key, value in experiment.dict(exclude_unset=True).items():
setattr(db_experiment, key, value)
db_experiment.updated_at = datetime.utcnow()
db.commit()
db.refresh(db_experiment)
return db_experiment
CORS 設定の柔軟な対応
開発環境では localhost からのアクセスを許可する必要があります。.env ファイルでの記述方法が揺れても動作するよう、設定クラスに正規化ロジックを追加しています。
from pydantic_settings import BaseSettings
from typing import Union, List
import json
class Settings(BaseSettings):
BACKEND_CORS_ORIGINS: Union[str, List[str]] = []
@validator("BACKEND_CORS_ORIGINS", pre=True)
def parse_cors_origins(cls, v):
if isinstance(v, str):
# JSON 配列形式の場合
if v.startswith("["):
return json.loads(v)
# カンマ区切りの場合
return [origin.strip() for origin in v.split(",")]
return v
これにより、.env に以下のいずれの形式で書いても動作します:
# JSON 配列形式
BACKEND_CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# カンマ区切り形式
BACKEND_CORS_ORIGINS=http://localhost:5173,http://localhost:3000
テストの実装
pytest と SQLite のインメモリDBを使って、CI 環境でも即座にテストを実行できるようにしています。
import pytest
from fastapi.testclient import TestClient
from sqlmodel import create_engine, Session, SQLModel
from app.main import app
from app.database import get_db
# テスト用のインメモリDB
SQLITE_URL = "sqlite:///:memory:"
engine = create_engine(SQLITE_URL, connect_args={"check_same_thread": False})
@pytest.fixture
def client():
SQLModel.metadata.create_all(engine)
def get_test_db():
with Session(engine) as session:
yield session
app.dependency_overrides[get_db] = get_test_db
with TestClient(app) as test_client:
yield test_client
SQLModel.metadata.drop_all(engine)
def test_create_experiment(client):
response = client.post(
"/api/v1/experiments/",
json={
"title": "Test Experiment",
"owner": "researcher1",
"description": "This is a test"
}
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Test Experiment"
assert "slug" in data
テストの実行:
uv run --extra dev pytest
フロントエンド実装
ディレクトリ構造
frontend/
├── src/
│ ├── App.tsx # アプリケーションのルート
│ ├── main.tsx # エントリーポイント
│ ├── api/
│ │ └── client.ts # API クライアント
│ ├── components/
│ │ ├── ExperimentList.tsx # 実験一覧
│ │ ├── ExperimentForm.tsx # 実験登録フォーム
│ │ └── FilterPanel.tsx # フィルタパネル
│ └── types/
│ └── experiment.ts # 型定義
├── package.json
├── tsconfig.json
└── vite.config.ts
API クライアントの実装
バックエンドとの通信を担当するAPIクライアントを実装します。
// src/api/client.ts
import axios from 'axios';
import { Experiment, ExperimentCreate, ExperimentUpdate } from '../types/experiment';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const apiClient = axios.create({
baseURL: `${API_BASE_URL}/api/v1`,
headers: {
'Content-Type': 'application/json',
},
});
export const experimentApi = {
// 実験一覧の取得
list: async (params?: {
owner?: string;
status?: string;
search?: string;
tag?: string;
}): Promise<Experiment[]> => {
const response = await apiClient.get('/experiments/', { params });
return response.data;
},
// 実験の登録
create: async (data: ExperimentCreate): Promise<Experiment> => {
const response = await apiClient.post('/experiments/', data);
return response.data;
},
// 実験の更新
update: async (id: number, data: ExperimentUpdate): Promise<Experiment> => {
const response = await apiClient.put(`/experiments/${id}`, data);
return response.data;
},
// 実験の削除
delete: async (id: number): Promise<void> => {
await apiClient.delete(`/experiments/${id}`);
},
};
実験一覧コンポーネント
実験の一覧を表形式で表示し、ステータスや担当者でフィルタリングできるようにします。
// src/components/ExperimentList.tsx
import React, { useState, useEffect } from 'react';
import { experimentApi } from '../api/client';
import { Experiment } from '../types/experiment';
export const ExperimentList: React.FC = () => {
const [experiments, setExperiments] = useState<Experiment[]>([]);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState({
owner: '',
status: '',
search: '',
});
const fetchExperiments = async () => {
setLoading(true);
try {
const data = await experimentApi.list(filters);
setExperiments(data);
} catch (error) {
console.error('Failed to fetch experiments:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchExperiments();
}, [filters]);
return (
<div className="experiment-list">
<div className="header">
<h2>実験一覧</h2>
<button onClick={fetchExperiments}>🔄 更新</button>
</div>
<table>
<thead>
<tr>
<th>タイトル</th>
<th>担当者</th>
<th>ステータス</th>
<th>タグ</th>
<th>作成日時</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{experiments.map((exp) => (
<tr key={exp.id}>
<td>
<a href={`/experiments/${exp.slug}`}>{exp.title}</a>
</td>
<td>{exp.owner}</td>
<td>
<span className={`status-badge ${exp.status}`}>
{exp.status}
</span>
</td>
<td>
{exp.tags.map((tag) => (
<span key={tag} className="tag">{tag}</span>
))}
</td>
<td>{new Date(exp.created_at).toLocaleDateString()}</td>
<td>
<button onClick={() => handleEdit(exp)}>編集</button>
<button onClick={() => handleDelete(exp.id)}>削除</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
};
実験登録フォーム
新しい実験を登録するためのフォームを実装します。
// src/components/ExperimentForm.tsx
import React, { useState } from 'react';
import { experimentApi } from '../api/client';
import { ExperimentCreate } from '../types/experiment';
export const ExperimentForm: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
const [formData, setFormData] = useState<ExperimentCreate>({
title: '',
owner: '',
description: '',
status: 'planned',
tags: [],
repo_url: '',
results_url: '',
notes: '',
});
const [tagInput, setTagInput] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await experimentApi.create(formData);
alert('実験を登録しました');
onSuccess();
// フォームをリセット
setFormData({
title: '',
owner: '',
description: '',
status: 'planned',
tags: [],
repo_url: '',
results_url: '',
notes: '',
});
} catch (error) {
console.error('Failed to create experiment:', error);
alert('実験の登録に失敗しました');
}
};
const addTag = () => {
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
setFormData({
...formData,
tags: [...formData.tags, tagInput.trim()],
});
setTagInput('');
}
};
return (
<form onSubmit={handleSubmit} className="experiment-form">
<h2>新規実験登録</h2>
<div className="form-group">
<label>タイトル *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
/>
</div>
<div className="form-group">
<label>担当者 *</label>
<input
type="text"
value={formData.owner}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
required
/>
</div>
<div className="form-group">
<label>概要</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
/>
</div>
<div className="form-group">
<label>ステータス</label>
<select
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
>
<option value="planned">計画中</option>
<option value="running">実行中</option>
<option value="review">レビュー中</option>
<option value="completed">完了</option>
<option value="archived">アーカイブ済み</option>
</select>
</div>
<div className="form-group">
<label>タグ</label>
<div className="tag-input">
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
placeholder="タグを入力してEnter"
/>
<button type="button" onClick={addTag}>追加</button>
</div>
<div className="tags">
{formData.tags.map((tag) => (
<span key={tag} className="tag">
{tag}
<button
type="button"
onClick={() =>
setFormData({
...formData,
tags: formData.tags.filter((t) => t !== tag),
})
}
>
×
</button>
</span>
))}
</div>
</div>
<div className="form-group">
<label>リポジトリURL</label>
<input
type="url"
value={formData.repo_url}
onChange={(e) => setFormData({ ...formData, repo_url: e.target.value })}
placeholder="https://github.com/username/repo"
/>
</div>
<div className="form-group">
<label>結果URL</label>
<input
type="url"
value={formData.results_url}
onChange={(e) => setFormData({ ...formData, results_url: e.target.value })}
placeholder="https://example.com/results"
/>
</div>
<div className="form-group">
<label>メモ</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
rows={3}
/>
</div>
<button type="submit" className="submit-button">
登録
</button>
</form>
);
};
TypeScript 設定
Vite と Node.js の型定義を適切に設定することで、Docker環境でもビルドエラーを防ぎます。
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable", "WebWorker"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "node"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Docker 環境構築
Docker Compose 設定
3つのサービス(db, backend, frontend)を定義し、一括で起動できるようにします。
# docker-compose.yml
version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: experiments
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/experiments
- BACKEND_CORS_ORIGINS=http://localhost:5173,http://localhost:3000
depends_on:
db:
condition: service_healthy
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "5173:80"
environment:
- VITE_API_URL=http://localhost:8000
depends_on:
- backend
volumes:
postgres_data:
バックエンド Dockerfile
# backend/Dockerfile
FROM python:3.11-slim
WORKDIR /app
# uv のインストール
RUN pip install uv
# 依存関係のコピーとインストール
COPY pyproject.toml .
RUN uv sync
# アプリケーションコードのコピー
COPY . .
# ヘルスチェック
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
フロントエンド Dockerfile (マルチステージビルド)
ビルドステージと配信ステージを分けることで、最終的なイメージサイズを削減します。
# frontend/Dockerfile
# ビルドステージ
FROM node:18 as build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# 配信ステージ
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# frontend/nginx.conf
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# API プロキシ設定 (オプション)
location /api {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Makefile による開発フロー標準化
チームメンバーが同じコマンドで操作できるよう、Makefile を用意します。
# Makefile
.PHONY: init up down logs backend-dev frontend-dev test fmt clean
init:
@echo "初期化中..."
cd backend && uv sync --extra dev
cd frontend && npm install
up:
docker-compose up --build -d
down:
docker-compose down
logs:
docker-compose logs -f
backend-dev:
cd backend && uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
frontend-dev:
cd frontend && npm run dev
test:
cd backend && uv run --extra dev pytest
fmt:
cd backend && uv run ruff format .
cd frontend && npm run format
clean:
docker-compose down -v
rm -rf backend/.venv
rm -rf frontend/node_modules
環境変数の設定
.env.example を基準に .env ファイルを作成します。
# .env.example
# バックエンド設定
DATABASE_URL=postgresql://postgres:postgres@db:5432/experiments
BACKEND_CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# フロントエンド設定
VITE_API_URL=http://localhost:8000
初回セットアップ時:
cp .env.example .env
# 必要に応じて .env を編集
起動と動作確認
システムの起動
# 依存関係のインストール
make init
# Docker コンテナの起動
make up
起動には数十秒かかることがあります。以下のコマンドでログを確認できます:
make logs
動作確認
バックエンドのヘルスチェック
curl http://localhost:8000/health
# レスポンス: {"status": "healthy"}
API ドキュメントの確認
ブラウザで http://localhost:8000/docs にアクセスすると、FastAPI が自動生成した Swagger UI でAPIを試すことができます。
フロントエンドの確認
ブラウザで http://localhost:5173 にアクセスすると、実験管理画面が表示されます。
実験の登録と確認
-
「新規実験登録」フォームから以下を入力:
- タイトル: "BERT Fine-tuning on SQuAD"
- 担当者: "田中太郎"
- ステータス: "running"
- タグ: "NLP", "BERT", "SQuAD"
- リポジトリURL: "https://github.com/example/bert-squad"
-
「登録」ボタンをクリック
-
実験一覧テーブルに即座に反映されます
-
リロードボタン(🔄)でバックエンドから再取得し、データの永続化を確認
トラブルシューティング
CORS エラーが発生する
症状: フロントエンドからAPIを呼び出すと、ブラウザのコンソールに CORS エラーが表示される
解決方法:
-
.envのBACKEND_CORS_ORIGINSに フロントエンドのURLが含まれているか確認 - バックエンドコンテナを再起動:
docker-compose restart backend
データベース接続エラー
症状: バックエンドが起動時に "could not connect to server" エラーを出す
解決方法:
- DB コンテナが起動しているか確認:
docker-compose ps - ヘルスチェックが通っているか確認:
docker-compose logs db -
docker-compose.ymlのdepends_onで health check を待つように設定されているか確認
フロントエンドのビルドエラー
症状: npm run build で型エラーが出る
解決方法:
-
tsconfig.jsonに"types": ["vite/client", "node"]が含まれているか確認 -
@types/nodeがインストールされているか確認:npm install --save-dev @types/node - node_modules を削除して再インストール:
rm -rf node_modules && npm install
実際の運用例
実験登録のワークフロー
研究室での典型的な利用フローは以下の通りです:
-
実験計画の登録
- 新しいアイデアを思いついたら、まず
plannedステータスで登録 - タイトル、概要、担当者、タグを記入
- チームメンバーに共有し、フィードバックをもらう
- 新しいアイデアを思いついたら、まず
-
実験の実行
- コードの準備ができたら、リポジトリURLを追加
- ステータスを
runningに変更 - 実行中の気づきを notes フィールドに記録
-
結果の共有
- 実験完了後、結果をJupyter NotebookやWandBにまとめる
- results_url に結果ページのリンクを追加
- ステータスを
reviewに変更し、チームレビューを依頼
-
完了とアーカイブ
- レビューが完了したら
completedに変更 - 論文執筆等で使用した実験は残し、その他は
archivedに
- レビューが完了したら
フィルタリングの活用例
担当者別の実験一覧
Owner: 田中太郎
→ 田中さんが現在進行中の実験を俯瞰
タグでの絞り込み
Tag: NLP
→ 自然言語処理関連の実験だけを表示
キーワード検索
Search: BERT
→ タイトルや概要に「BERT」を含む実験を検索
ステータスフィルタ
Status: running
→ 現在実行中の実験だけを表示し、リソース競合を回避
拡張アイデア
現在の基本機能に加えて、以下のような拡張が可能です。
Slack 連携
実験の登録・更新時に Slack に通知を送ることで、チーム全体の可視性を高めます。
# backend/app/integrations/slack.py
import requests
from app.config import settings
def send_slack_notification(message: str):
if not settings.SLACK_WEBHOOK_URL:
return
requests.post(
settings.SLACK_WEBHOOK_URL,
json={"text": message}
)
# 実験登録時
@router.post("/", response_model=Experiment)
async def create_experiment(experiment: ExperimentCreate, db: Session = Depends(get_db)):
# ... 実験登録処理 ...
send_slack_notification(
f"🔬 新しい実験が登録されました\n"
f"タイトル: {db_experiment.title}\n"
f"担当者: {db_experiment.owner}\n"
f"URL: {settings.FRONTEND_URL}/experiments/{db_experiment.slug}"
)
return db_experiment
GitHub 連携
GitHub Actions と連携し、実験実行時のコミットハッシュを自動記録します。
# .github/workflows/record-experiment.yml
name: Record Experiment
on:
workflow_dispatch:
inputs:
experiment_id:
description: 'Experiment ID'
required: true
jobs:
record:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Record commit hash
run: |
COMMIT_HASH=$(git rev-parse HEAD)
curl -X PUT \
${{ secrets.API_URL }}/api/v1/experiments/${{ github.event.inputs.experiment_id }} \
-H "Content-Type: application/json" \
-d "{\"external_links\": {\"commit\": \"$COMMIT_HASH\"}}"
認証・認可 (RBAC)
研究室メンバーごとにアクセス権限を制御します。
役割の定義:
-
Admin: 全実験の閲覧・編集・削除が可能 -
Member: 自分の実験のみ編集可能、他人の実験は閲覧のみ -
Guest: 閲覧のみ
実装例 (FastAPI + OAuth2):
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
async def get_current_user(token: str = Depends(oauth2_scheme)):
# JWT トークンの検証
user = decode_token(token)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials"
)
return user
@router.delete("/{experiment_id}")
async def delete_experiment(
experiment_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
experiment = db.query(Experiment).filter(Experiment.id == experiment_id).first()
if not experiment:
raise HTTPException(status_code=404, detail="Experiment not found")
# 権限チェック
if experiment.owner != current_user.username and current_user.role != "admin":
raise HTTPException(status_code=403, detail="Permission denied")
db.delete(experiment)
db.commit()
ファイルアップロード機能
実験結果のファイル(CSVや画像など)をアップロードし、S3やローカルストレージに保存します。
from fastapi import UploadFile, File
@router.post("/{experiment_id}/upload")
async def upload_file(
experiment_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db)
):
# ファイルを S3 にアップロード
file_url = await upload_to_s3(file)
# 実験レコードに URL を追加
experiment = db.query(Experiment).filter(Experiment.id == experiment_id).first()
if experiment.external_links is None:
experiment.external_links = {}
experiment.external_links["uploaded_files"] = experiment.external_links.get("uploaded_files", [])
experiment.external_links["uploaded_files"].append({
"filename": file.filename,
"url": file_url
})
db.commit()
return {"url": file_url}
実験比較機能
複数の実験を選択し、パラメータや結果を並べて比較する機能を追加します。
// frontend/src/components/ExperimentComparison.tsx
export const ExperimentComparison: React.FC<{ experimentIds: number[] }> = ({ experimentIds }) => {
const [experiments, setExperiments] = useState<Experiment[]>([]);
useEffect(() => {
// 複数の実験を並行して取得
Promise.all(experimentIds.map(id => experimentApi.get(id)))
.then(setExperiments);
}, [experimentIds]);
return (
<table className="comparison-table">
<thead>
<tr>
<th>項目</th>
{experiments.map(exp => (
<th key={exp.id}>{exp.title}</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td>担当者</td>
{experiments.map(exp => <td key={exp.id}>{exp.owner}</td>)}
</tr>
<tr>
<td>ステータス</td>
{experiments.map(exp => <td key={exp.id}>{exp.status}</td>)}
</tr>
{/* 他のフィールドも同様に比較 */}
</tbody>
</table>
);
};
まとめ
本記事では、研究室での実験管理を透明化するためのシステム構築について解説しました。
得られる効果
- 可視化: 全実験を一覧で把握し、プロジェクトの進捗を可視化
- 検索性: タグやキーワードで過去の実験を即座に検索
- 共有: 実験結果やリポジトリへのリンクを一元管理
- 再現性: 実験条件やコードを記録し、再実行を容易に
- 協調: チームメンバー間で実験情報を共有し、重複を回避
技術選定のポイント
- FastAPI: 高速な開発と自動ドキュメント生成
- React + TypeScript: 保守性の高いフロントエンド
- PostgreSQL: 信頼性とJSONB型による柔軟性
- Docker Compose: 環境の再現性と配布の容易さ
- uv: 高速な依存管理と仮想環境
今後の展開
このシステムは基本的なCRUD機能を提供していますが、研究室の規模や用途に応じて以下のような拡張が可能です:
- Slack/GitHub連携による自動通知
- 認証・認可によるアクセス制御
- ファイルアップロード機能
- 実験結果の可視化ダッシュボード
- MLflowやWandBとの統合
研究開発の透明性を高め、チーム全体の生産性を向上させるために、ぜひこのようなシステムの導入を検討してみてください。
参考リンク
ソースコードは以下のリポジトリで公開しています:
Discussion