🪄

研究室の実験管理を透明化する

に公開

はじめに

研究室で機械学習やLLMの実験を進めていると、こんな課題に直面することはありませんか?

  • 「誰がどんな実験をしているのか分からない」
  • 「過去の実験結果がどこにあるか探すのに時間がかかる」
  • 「同じような実験を別の人が重複して行ってしまう」
  • 「実験の進捗状況が見えず、プロジェクト全体の把握が難しい」

本記事では、これらの課題を解決するために、研究室メンバー全員で実験情報を共有できる実験管理システムを構築した経験を共有します。

FastAPI + React + PostgreSQL の構成で、Docker化により「誰でも同じ環境」で動かせるシステムを目指しました。

なぜ実験管理システムが必要なのか

研究室における実験管理の課題

研究開発の現場では、日々多くの実験が並行して進められます。特に機械学習やLLMの分野では:

  • 実験の数が膨大: ハイパーパラメータの組み合わせだけで数十〜数百の実験が生まれる
  • 担当者が複数: 複数人が異なるアプローチで実験を進める
  • 結果の散在: Jupyter Notebook、スプレッドシート、Slackメッセージなど、情報が分散
  • 再現性の確保: 数ヶ月後に「あの実験はどうやったんだっけ?」となりがち

実験管理システムが提供する価値

実験管理システムを導入することで、以下のような透明性向上が期待できます:

  1. 可視化: 全実験の一覧を俯瞰し、プロジェクトの進捗を把握
  2. 検索性: タグや担当者でフィルタリングし、関連実験をすぐに発見
  3. 共有: 実験結果へのリンクを一元管理し、知見を共有
  4. 再現性: 実験条件やリポジトリ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 にアクセスすると、実験管理画面が表示されます。

実験の登録と確認

  1. 「新規実験登録」フォームから以下を入力:

    • タイトル: "BERT Fine-tuning on SQuAD"
    • 担当者: "田中太郎"
    • ステータス: "running"
    • タグ: "NLP", "BERT", "SQuAD"
    • リポジトリURL: "https://github.com/example/bert-squad"
  2. 「登録」ボタンをクリック

  3. 実験一覧テーブルに即座に反映されます

  4. リロードボタン(🔄)でバックエンドから再取得し、データの永続化を確認

トラブルシューティング

CORS エラーが発生する

症状: フロントエンドからAPIを呼び出すと、ブラウザのコンソールに CORS エラーが表示される

解決方法:

  1. .env の BACKEND_CORS_ORIGINS に フロントエンドのURLが含まれているか確認
  2. バックエンドコンテナを再起動: docker-compose restart backend

データベース接続エラー

症状: バックエンドが起動時に "could not connect to server" エラーを出す

解決方法:

  1. DB コンテナが起動しているか確認: docker-compose ps
  2. ヘルスチェックが通っているか確認: docker-compose logs db
  3. docker-compose.yml の depends_on で health check を待つように設定されているか確認

フロントエンドのビルドエラー

症状: npm run build で型エラーが出る

解決方法:

  1. tsconfig.json に "types": ["vite/client", "node"] が含まれているか確認
  2. @types/node がインストールされているか確認: npm install --save-dev @types/node
  3. node_modules を削除して再インストール: rm -rf node_modules && npm install

実際の運用例

実験登録のワークフロー

研究室での典型的な利用フローは以下の通りです:

  1. 実験計画の登録

    • 新しいアイデアを思いついたら、まず planned ステータスで登録
    • タイトル、概要、担当者、タグを記入
    • チームメンバーに共有し、フィードバックをもらう
  2. 実験の実行

    • コードの準備ができたら、リポジトリURLを追加
    • ステータスを running に変更
    • 実行中の気づきを notes フィールドに記録
  3. 結果の共有

    • 実験完了後、結果をJupyter NotebookやWandBにまとめる
    • results_url に結果ページのリンクを追加
    • ステータスを review に変更し、チームレビューを依頼
  4. 完了とアーカイブ

    • レビューが完了したら 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>
  );
};

まとめ

本記事では、研究室での実験管理を透明化するためのシステム構築について解説しました。

得られる効果

  1. 可視化: 全実験を一覧で把握し、プロジェクトの進捗を可視化
  2. 検索性: タグやキーワードで過去の実験を即座に検索
  3. 共有: 実験結果やリポジトリへのリンクを一元管理
  4. 再現性: 実験条件やコードを記録し、再実行を容易に
  5. 協調: チームメンバー間で実験情報を共有し、重複を回避

技術選定のポイント

  • FastAPI: 高速な開発と自動ドキュメント生成
  • React + TypeScript: 保守性の高いフロントエンド
  • PostgreSQL: 信頼性とJSONB型による柔軟性
  • Docker Compose: 環境の再現性と配布の容易さ
  • uv: 高速な依存管理と仮想環境

今後の展開

このシステムは基本的なCRUD機能を提供していますが、研究室の規模や用途に応じて以下のような拡張が可能です:

  • Slack/GitHub連携による自動通知
  • 認証・認可によるアクセス制御
  • ファイルアップロード機能
  • 実験結果の可視化ダッシュボード
  • MLflowやWandBとの統合

研究開発の透明性を高め、チーム全体の生産性を向上させるために、ぜひこのようなシステムの導入を検討してみてください。

参考リンク


ソースコードは以下のリポジトリで公開しています:
https://github.com/uhey77/Experment-Management-System

Discussion