MLOpsを用いた適応型NERシステムの構築(本番)

Dev.to / 2026/3/13

💬 オピニオンDeveloper Stack & InfrastructureTools & Practical UsageModels & Research

要点

  • MLOpsを用いて適応型NERシステムを構築することに焦点を当てた本番環境向けガイドを提示し、ローカルのPoCから完全に自律的な本番ラインへと拡張します。
  • 日次の合成データ生成を自動化し、ルールベースと機械学習のハイブリッドモデルを訓練し、対話型レポートをGitHub Pagesにデプロイする。すべてGitHub Actionsを介して実行されます。
  • インテリジェントなキャッシュ戦略によりパフォーマンスを向上させ、GitHubの無料プランでコストゼロで動作することを主張します。
  • 1000件の取引を処理し、5分未満で自動的にレポートを公開するパイプラインなどの成果を実証し、ライブデモが利用可能です。
  • 詳細な目次を含み、本番環境での課題とアーキテクチャの深掘りについて解説します。

GitHub Actionsで24時間365日、人の介入なしに稼働する自走型の本番プロダクション・パイプラインへ—取引分類システムをコンセプトから実現した方法

前回のガイド では、このシステムをローカルで構築する方法を説明しましたが、ここでは一段進めて、実際に本番環境向けに作り込みます。

これから、次のような強化 Named Entity Recognition(NER)システムを構築し、本番運用までプロダクション化する道のりを案内します:

  • 合成データを自動生成(毎日)
  • ハイブリッドなルールベース+機械学習でMLモデルを学習
  • インタラクティブなレポートをGitHub Pagesへ自動デプロイ
  • インテリジェントなキャッシュ戦略で3倍高速
  • GitHub Actionsの無料枠で月額$0

ライブデモ: https://akanimohod19a.github.io/productionizing_NER/

結果: 1,000件の取引を処理し、モデルを学習し、美しいレポートを公開する—これらをすべて完全に自律的に、5分未満で実行できる本番レベルのMLパイプラインです。

目次

  1. 解決した課題
  2. 最初のPOC:最初に着手したこと
  3. 直面した本番環境の課題
  4. 解決策1:インテリジェントなキャッシュの実装
  5. 解決策2:無効な日付バグの修正
  6. 解決策3:CI/CDにおける動的データ生成
  7. 解決策4:包括的なテスト戦略
  8. アーキテクチャの深掘り
  9. パフォーマンス指標:改善前 vs 改善後
  10. 学び
  11. 次にやること

解決した課題

ビジネス上の背景

金融機関は、日々何百万件ものフリーテキストの取引説明を処理しています。それは次のような見た目です:

"walmart grocery shopping"
"cvs pharmacy prescription pickup"  
"uber ride to downtown"
"payment to acme corp inv-2024-001"

課題:

  • 手作業での分類は規模的に不可能
  • ルールベースのシステムでは新しいパターンを取りこぼす
  • 従来のMLは継続的な再学習が必要
  • モデルの性能が見えない
  • レポートは静的で、古くなる

私たちが作ったもの

自己改善型の分類システムで、次のことを行います:

  1. 現実的なテストデータを自動生成する
  2. ルールベースとMLによる分類を組み合わせる
  3. クラスタリングによって新しいカテゴリを発見する
  4. すべてをMLflowで追跡する
  5. インタラクティブなレポートをWebに公開する
  6. GitHub Actionsで完全に自律的に動作させる

しかも、実行コストはゼロです!

最初のPOC:最初に着手したこと

元の実装

私たちのPoC(概念実証)は、3つの主要コンポーネントから成っていました:

1. ルールベースの分類器

# models/keyword_rules.yaml
categories:
  Healthcare:
    keywords: [pharmacy, doctor, hospital, medical]
    weight: 1.5

  Groceries:
    keywords: [walmart, grocery, supermarket]
    weight: 1.0

カバー率: 分類された取引の68.5%を即時に処理。

2. MLによる強化

from sklearn.ensemble import RandomForestClassifier
from sklearn.feature_extraction.text import TfidfVectorizer

# 金額に基づく重み付けによる学習
sample_weights = np.log1p(df['amount'].abs())
classifier = RandomForestClassifier(n_estimators=100)
classifier.fit(X, y, sample_weight=sample_weights)

改善: カバー率が+22.7%(合計:91.2%)

3. 教師なしの発見

from sklearn.cluster import DBSCAN

# 未知の取引に含まれるパターンを見つける
clustering = DBSCAN(eps=0.3, min_samples=3, metric='cosine')
labels = clustering.fit_predict(X)

返却形式: {"translated": "翻訳されたHTML"}# 発見: 「Insurance」カテゴリ
# 対象: ["geico auto", "state farm policy", "allstate premium"]

POCの結果

指標
分類カバレッジ 91.2%
処理速度 0.8ms/トランザクション
金額加重精度 96.8%

POCは機能しました。 しかし手作業で、遅く、本番環境向けではありませんでした。そこで、自律的に動かし、人間の介入を最小限に抑えるように構築する計画を立てました、 ただしそれでも、独自の課題がありました。

私たちが直面した本番環境での課題

課題1: ビルド時間が長い

問題: 当初、各GitHub Actionsの実行に12分以上かかっていました。

├─ Pythonパッケージのインストール:     4m 30s
├─ Rパッケージのインストール:          6m 15s  
├─ テストの実行:                   1m 20s
├─ レポートの生成:             2m 45s
└─ 合計:                       12m 50s

重要だった理由: フィードバックの遅さは、開発の遅さにつながります。

課題2: 不正なタイムスタンプ

問題: 公開されたレポートに、パースの問題によりダッシュボード上で「Invalid Date」が表示されていました。

// ダッシュボードがパースしようとした:
timestamp: "20260313_143522"

// しかしJavaScriptのDate()はこれを想定している:
timestamp: "2026-03-13T14:35:22"

影響: プロフェッショナルなダッシュボードが壊れたように見えました。

課題3: 古いテストデータ

問題: テストは、古いコミット済みのCSVファイルに対して実行されていました。ワークフローはdata genから始まるため、システム全体はそのバージョンのレコードで動作すべきでした。
ただし、これは現実のシナリオでランダムなレコードを使ってテストしていたために起きています。つまり、データソースを参照していなかった、ということです。完全に、です。

# テストは常にこの同じファイルを使用:
tests/fixtures/sample_transactions.csv

# しかし実際のパイプラインは毎日新しいデータを生成していました!

リスク: テストは通るのに、本番で失敗する。

課題4: 目に見える状況がない

問題: テストが失敗したとき、ログを掘り下げる必要がありました。

FAILED tests/test_classifier.py::test_groceries_classification
ValueError: 値が足りません (3を期待していたが2でした)

フラストレーション: 分かりにくいエラーで、明確な解決策がない。

そこで、解決策を調べました。

解決策1: インテリジェントなキャッシュの実装

戦略

実行のたびに変わらないものをすべてキャッシュするために、マルチレイヤーのキャッシュ戦略を導入しました。

レイヤー1: Pythonパッケージのキャッシュ

変更前:

- name: 依存関係をインストール
  run: pip install -r requirements.txt
  # 時間: 毎回約4分

変更後:

- name: Pythonをセットアップ
  uses: actions/setup-python@v5
  with:
    python-version: '3.9'
    cache: 'pip'  # ← pipの組み込みキャッシュ

- name: Pythonパッケージをキャッシュ
  uses: actions/cache@v4
  with:
    path: ~/.cache/pip
    key: ${{ runner.os }}-pip-v1-${{ hashFiles('requirements.txt') }}
    restore-keys: |
      ${{ runner.os }}-pip-v1-
      ${{ runner.os }}-pip-

仕組み:

  1. 初回実行: パッケージをダウンロードしてキャッシュ(4分)
  2. 以降の実行: キャッシュから復元(15秒)
  3. requirements.txtが変更された場合のみ再ダウンロード

結果: 1回の実行につき3.75分節約できました!

レイヤー2: Rパッケージのキャッシュ

Rパッケージは非常に大きく、コンパイルに永遠に近い時間がかかります。

変更前:

- name: Rの依存関係をインストール
  run: |
    install.packages(c("tidyverse", "plotly", "DT", ...))
  # 時間: 約6分

変更後:

- name: Rパッケージをキャッシュ
  uses: actions/cache@v4
  with:
    path: ${{ env.R_LIBS_USER }}
    key: ${{ runner.os }}-r-v1-${{ hashFiles('DESCRIPTION') }}

- name: Rの依存関係をインストール
  uses: r-lib/actions/setup-r-dependencies@v2
  with:
    packages: |
      any::tidyverse
      any::knitr
      any::rmarkdown

これが素晴らしい理由:

返却形式: {"translated": "翻訳されたHTML"}
  • r-lib/actions は RStudio によってメンテナンスされています
  • OS 固有のコンパイルを処理します
  • ソースではなくバイナリパッケージをキャッシュします

結果: 5.5 分削減!

Layer 3: Pytest Cache

テストは、再利用可能なフィクスチャとメタデータを生成します。

実装:

- name: Cache pytest
  uses: actions/cache@v4
  with:
    path: .pytest_cache
    key: ${{ runner.os }}-pytest-v1-${{ hashFiles('tests/**/*.py') }}

- name: Run tests
  run: pytest tests/ -v --cov=src/python

キャッシュされるもの:

  • テストの検出結果
  • フィクスチャのコンパイル
  • カバーデータ構造

結果: 30 秒削減。さらにローカルでのテストが高速化!

Layer 4: MLflow Artifacts

ML の実験では、大量のメタデータが生成されます。

- name: Cache MLflow artifacts
  uses: actions/cache@v4
  with:
    path: mlruns
    key: ${{ runner.os }}-mlflow-v1-${{ github.sha }}
    restore-keys: |
      ${{ runner.os }}-mlflow-v1-

キャッシュされる内容:

  • モデルパラメータ
  • メトリクス履歴
  • アーティファクトのメタデータ

効果: MLflow UI の読み込みが高速化し、実験の比較も容易になります。

The Cache Strategy Matrix

Layer Size Build Time Cache Hit Rate Time Saved
Python packages 200 MB 4m 30s 95% 4m 15s
R packages 800 MB 6m 15s 90% 5m 30s
Pytest cache 5 MB 30s 85% 25s
MLflow artifacts 50 MB - 80% -

合計の削減時間: 実行あたり約 10 分!

Cache Invalidation Strategy

キャッシュキーにはセマンティック バージョニングを使用します:

env:
  CACHE_VERSION: v1  # すべてのキャッシュを無効化するためにインクリメントします

key: ${{ runner.os }}-pip-${{ env.CACHE_VERSION }}-${{ hashFiles('requirements.txt') }}

いつバージョンを上げるか:

  • 主要な依存関係のアップグレード
  • OS イメージの変更
  • キャッシュの破損が疑われる場合

プロのコツ: 一部のキャッシュヒットにはrestore-keysを使います:

restore-keys: |
  ${{ runner.os }}-pip-v1-
  ${{ runner.os }}-pip-

これによりフォールバック階層が提供されます:

  1. 完全一致を試す(requirements.txt のハッシュ)
  2. v1 のどれかのキャッシュを試す
  3. pip のどれかのキャッシュを試す

結果: キャッシュヒット率が 60% から 95% に増加しました!

Solution 2: Fixing the Invalid Date Bug

The Root Cause

ダッシュボードでは JavaScript を使ってタイムスタンプを解析していました:

// 生成していたもの:
{
  "timestamp": "20260313_143522"
}

// JavaScript の Date() が期待していたもの:
{
  "timestamp": "2026-03-13T14:35:22.000Z"
}

The Investigation

ステップ 1: マニフェストの生成を確認する

# 元の(壊れていた)コード:
timestamp_str = filename.replace('assessment_report_', '').replace('.html', '')
# 結果: "20260313_143522"
reports.append({
    'timestamp': timestamp_str  # ❌ ISO 形式ではありません!
})

ステップ 2: ブラウザのコンソールでテストする

new Date("20260313_143522")
// Invalid Date

new Date("2026-03-13T14:35:22")
// Wed Mar 13 2026 14:35:22 GMT+0000 (UTC) ✓

The Fix

更新されたマニフェスト生成:

from datetime import datetime

返却形式: {"translated": "翻訳されたHTML"}# ファイル名のタイムスタンプを解析する
timestamp_str = filename.replace(' assessment_report_', '').replace(' .html', '')

try:
    # 書式: YYYYMMDD_HHMMSS
    dt = datetime.strptime(timestamp_str, '%Y%m%d_%H%M%S')

    # ISO 8601 形式に変換する
    iso_timestamp = dt.isoformat()  # "2026-03-13T14:35:22"
except:
    # 解析に失敗した場合は現在時刻にフォールバックする
    iso_timestamp = datetime.now().isoformat()

reports.append({
    ' id': timestamp_str,
    ' timestamp': iso_timestamp,  # ✓ ISO 形式
    ' url': f' reports/{timestamp_str}/{report_file.name}'
})

結果

Before:

┌──────────┐
│ 無効な  │
│   日付   │
└──────────┘

After:

┌──────────┐
│  3月13日 │
│   2026   │
└──────────┘

JavaScript の改善

ダッシュボード上での日時の表示形式も改善しました:

const date = new Date(report.timestamp);

// 表示用の書式
const formattedDate = date.toLocaleString('en-US', {
  year: 'numeric',
  month: 'long',
  day: 'numeric',
  hour: '2-digit',
  minute: '2-digit'
});
// "2026年3月13日 14:35"(※環境により表記が変わる可能性があります)


// ステータスカード用の書式
const shortDate = date.toLocaleDateString('en-US', {
  month: 'short',
  day: 'numeric'
});
// "3月13日"

重要な学び: API やデータのやり取りでは、タイムスタンプに常に ISO 8601 形式を使ってください!

解決策 3: CI/CD における動的データ生成

静的なテストデータに関する問題

元のワークフローではコミット済みの CSV ファイルを使用していました:

# 旧ワークフロー
- name: モデルを学習する
  run: python src/python/train_model.py data/sample_transactions.csv
  #                                      ↑ リポジトリからの静的ファイル

問題:

  1. テストは常に同じデータに対して実行される
  2. 実際のパイプラインは毎日新しいデータを生成する
  3. エッジケースをテストする方法がない
  4. 古いデータは本番データと同じではない

解決策: CI/CD でデータを生成する

データ生成をパイプラインの 最初のステップ にしました:

jobs:
  # ジョブ 1: 新しいデータを生成する
  generate-data:
    runs-on: ubuntu-latest
    outputs:
      data_file: ${{ steps.generate.outputs.data_file }}
      timestamp: ${{ steps.generate.outputs.timestamp }}

返却形式: {"translated": "翻訳されたHTML"}steps:
      - name: 合成取引データを生成する
        id: generate
        run: |
          TIMESTAMP=$(date +%Y%m%d_%H%M%S)
          DATA_SIZE=${{ github.event.inputs.data_size || '1000' }}
          DATA_FILE="data/transactions_${TIMESTAMP}.csv"

          python scripts/generate_sample_data.py \
            --size ${DATA_SIZE} \
            --output ${DATA_FILE}

          # 次のジョブに渡す
          echo "data_file=${DATA_FILE}" >> $GITHUB_OUTPUT
          echo "timestamp=${TIMESTAMP}" >> $GITHUB_OUTPUT

アーティファクトでジョブを接続する

ジェネレーターからアップロード:

- name: データのアーティファクトをアップロードする
  uses: actions/upload-artifact@v4
  with:
    name: transaction-data-${{ steps.generate.outputs.timestamp }}
    path: |
      ${{ steps.generate.outputs.data_file }}
      data/*_metadata.json
    retention-days: 7

トレーニングジョブでダウンロード:

train-model:
  needs: [generate-data, test]  # データ生成を待つ

  steps:
    - name: 取引データをダウンロードする
      uses: actions/download-artifact@v4
      with:
        name: transaction-data-${{ needs.generate-data.outputs.timestamp }}
        path: data/

    - name: NER(固有表現)分類器をトレーニングする
      run: |
        DATA_FILE="${{ needs.generate-data.outputs.data_file }}"
        python src/python/train_model.py ${DATA_FILE}

動的データの利点

1. 実行ごとに新しいデータ

# 毎日異なるデータ
2026-03-13: 1000 取引現在のパターン
2026-03-14: 1000 NEW 取引NEWパターン

2. 設定可能なサイズ

workflow_dispatch:
  inputs:
    data_size:
      description: '取引の 数'
      default: '1000'

次でテストできます:

  • スモークテスト用に100
  • 通常実行用に1,000
  • ストレステスト用に10,000

3. 現実的な分布

# ジェネレーターが現実的な混在を作成する:
{
  '食料品': 25%,
  'レストラン': 18%,
  '交通': 15%,
  '医療': 10%,
  '不明': 5%,
  # ... など
}

4. メタデータの追跡

{
  "generated_at": "2026-03-13T14:35:22",
  "n_transactions": 1000,
  "category_distribution": {...},
  "amount_stats": {
    "min": 5.50,
    "max": 1200.00,
    "mean": 87.43
  }
}

データジェネレーター

私たちの合成データジェネレーターは、現実的な取引を作成します:

返却形式: {"translated": "翻訳されたHTML"}
class TransactionGenerator:
    def __init__(self, seed=None):
        if seed:
            np.random.seed(seed)

        self.templates = {
            'Groceries': {
                'merchants': ['walmart', 'costco', 'whole foods'],
                'items': ['grocery', 'bread milk eggs', 'produce'],
                'amount_range': (30, 250),
                'frequency': 0.25
            },
            # ... 8 categories total
        }

    def generate_narration(self, category):
        merchant = np.random.choice(self.templates[category]['merchants'])
        item = np.random.choice(self.templates[category]['items'])

        # 異なるパターン
        patterns = [
            f"{merchant} {item}",
            f" {item}{merchant}で購入
",
            f"{item}{merchant}"
        ]

        narration = np.random.choice(patterns)

        # ときどき参照番号を追加
        if np.random.random() > 0.7:
            ref = np.random.randint(1000, 9999)
            narration += f" ref#{ref}"

        return narration

Example output:

walmart grocery ショッピング ref#4521
cvs 調剤薬局で購入
uber 乗車 ダウンタウン
starbucksでコーヒー

テストへの影響

Before: テストは常に固定データで合格
After: テストが実際のエッジケースを検出

Example bug we caught:

返却形式: {"translated": "翻訳されたHTML"}
# バグ: 'amount' が常に存在すると仮定していた
def classify(df):
    return df['amount'].abs()  # ❌ amount が欠けていると失敗する

# 修正: 欠けている amount を扱う
def classify(df):
    if 'amount'not in df.columns:
        df['amount'] = 0
    return df['amount'].abs()  # ✓ 動作する

このバグは生成データに amount が欠けている場合にだけ発生しました!

解決策 4: 包括的なテスティング戦略

テストピラミッド

私たちは完全なテスティング戦略を実装しました:

           /\
          /  \
         /E2E \          3 tests (5%)
        /______\
       /        \
      /Integration\      7 tests (28%)
     /____________\
    /              \
   /  Unit Tests    \    15 tests (60%)
  /__________________\

レイヤー 1: ユニットテスト

個々のコンポーネントを単独でテストする:

# tests/test_classifier.py
class TestKeywordMatching:
    def test_healthcare_classification(self, classifier):
        """医療取引の分類をテストする。"""
        category, confidence = classifier.keyword_match(
            "cvs pharmacy prescription pickup"
        )

        assert category == "Healthcare"
        assert confidence > 0.3

カバレッジ:

  • ルールベースの分類 ✓
  • ML の特徴量抽出 ✓
  • 信頼度スコアリング ✓
  • データ生成 ✓

これが重要な理由:

  • 高速なフィードバック(< 1 秒)
  • 正確な失敗箇所を特定できる
  • デバッグが容易

レイヤー 2: 統合テスト

コンポーネントが連携して動くことをテストする:

# tests/test_pipeline.py
def test_full_pipeline(tmp_path):
    """完全なパイプラインの実行をテストする。"""
    # データを生成
    generator = TransactionGenerator(seed=42)
    df = generator.generate_transactions(100)

    # 分類する
    classifier = AdaptiveNERClassifier()
    results = classifier.classify_batch(df)

    # 確認する
    assert len(results) >= 100
    unknown_rate = (results['category'] == 'Unknown').sum() / len(results)
    assert unknown_rate < 0.9  # 不明が 90% 未満

私たちがテストしていること:

  • データ → 分類器 → 結果 のフロー
  • ファイル I/O 操作
  • MLflow のトラッキング統合
  • レポート生成のエンドツーエンド

レイヤー 3: エンドツーエンドテスト

ユーザーの動きと同じようにワークフロー全体をテストする:

def test_github_actions_simulation():
    """GitHub Actions のワークフロー全体をシミュレートする。"""
    # ステップ 1: データを生成
    subprocess.run([
        'python', 'scripts/generate_sample_data.py',
        '--size', '100',
        '--output', 'data/test.csv'
    ])
# ステップ2: モデルを訓練する subprocess.run([ 'python', 'src/python/train_model.py', 'data/test.csv' ]) # ステップ3: レポートを生成する subprocess.run([ 'Rscript', '-e', "rmarkdown::render('reports/assessment_report.Rmd')" ]) # 出力が存在することを確認する assert Path('models/ner_classifier.pkl').exists() assert Path('reports/assessment_report.html').exists()

テストフィクスチャ戦略

共有するテストデータにはpytestフィクスチャを使用します:

# tests/conftest.py
@pytest.fixture
def classifier():
    """再利用可能な分類器インスタンス。"""
    return AdaptiveNERClassifier(rules_path="models/keyword_rules.yaml")

@pytest.fixture
def sample_transactions():
    """再利用可能なサンプルデータ。"""
    return pd.DataFrame({
        'narration': [
            'cvs pharmacy prescription',
            'walmart grocery shopping',
            'uber ride downtown'
        ],
        'amount': [45.00, 125.50, 28.00]
    })

メリット:

  • コードの重複がない
  • テストデータが一貫している
  • 全体的に更新しやすい

不安定なテストの修正

問題: テストが間欠的に失敗した

# 不安定なテスト(良くない)
def test_generate_transactions():
    df = generator.generate_transactions(100)
    assert len(df) == 100  # ❌ 不明なデータのせいで105になることがある

解決策: アサーションを柔軟にする

# 堅牢なテスト(良い)
def test_generate_transactions():
    df = generator.generate_transactions(100)
    assert 100 <= len(df) <= 110  # ✓ 不明なデータが約5%あることを考慮

テストカバレッジの目標

重要な経路について80%のカバレッジを目指しました:

pytest tests/ --cov=src/python --cov-report=term

Name                              Stmts   Miss  Cover
-----------------------------------------------------
src/python/ner_classifier.py        145     12    92%
src/python/train_model.py           89      8    91%
src/python/category_discovery.py    76     15    80%
-----------------------------------------------------
TOTAL                               310     35    87%

カバレッジレポートは自動的にCodecovへアップロードされます:

- name: Upload coverage
  uses: codecov/codecov-action@v3
  with:
    file: ./coverage.xml

結果: READMEに素敵なカバレッジバッジ!

Coverage

アーキテクチャの詳細分析

完全なパイプラインの流れ

返却形式: {"translated": "翻訳されたHTML"}
┌─────────────────────────────────────────────────────────┐
│                    GitHub Actions                       │
│                   (トリガー: 毎日 午前2時)                │
└────────────┬────────────────────────────────────────────┘
             │
             ▼
┌─────────────────────────────────────────────────────────┐
│  ジョブ1: データ生成(15秒)                          │
│  ┌──────────────────────────────────────────┐           │
│  │ Python: generate_sample_data.py          │           │
│  │ 出力: transactions_20260313_143522.csv │           │
│  │ メタデータ: カテゴリ分布、統計   │           │
│  └──────────────────────────────────────────┘           │
└────────────┬────────────────────────────────────────────┘
             │ アーティファクトのアップロード
             ▼
┌─────────────────────────────────────────────────────────┐
│  ジョブ2: テストの実行(30秒 - キャッシュ済み)         │
│  ┌──────────────────────────────────────────┐           │
│  │ pytest tests/ --cov=src/python           │           │
│  │ カバレッジ: 87%                            │           │
│  │ Codecovへアップロード                        │           │
│  └──────────────────────────────────────────┘           │
└────────────┬────────────────────────────────────────────┘
             │ テスト成功 ✓
             ▼
┌─────────────────────────────────────────────────────────┐
│  ジョブ3: モデルの学習(2分 - キャッシュ済み)           │
│  ┌──────────────────────────────────────────┐           │
│  │ ルールベース分類(68.5%)        │           │
│  │ ↓                                        │           │
│  │ 機械学習による強化(+22.7%)      │           │
│  │ ↓                                        │           │
│  │ カテゴリ発見(新しいクラスタ4件)     │           │
│  │ ↓                                        │           │
│  │ MLflow ロギング(メトリクス、モデル、アーティファクト)│          │
│  └──────────────────────────────────────────┘           │
└────────────┬────────────────────────────────────────────┘
             │ アーティファクトのアップロード
             ▼
┌─────────────────────────────────────────────────────────┐
│  ジョブ4: レポートの生成(90秒 - キャッシュ済み)         │
│  ┌──────────────────────────────────────────┐           │
│  │ R Markdown レンダリング                     │           │
│  │ ├─ classified_transactions.csv を読み込み     │            │ 
│  │ ├─ 統計を計算                 │            │
│  │ ├─ インタラクティブなグラフを12個作成       │            │
│  │ ├─ 推奨事項を生成             │            │
│  │ └─ 出力: assessment_report.html       │            │
│  └──────────────────────────────────────────┘           │
└────────────┬────────────────────────────────────────────┘
             │ アーティファクトのアップロード
             ▼
┌─────────────────────────────────────────────────────────┐
│  ジョブ5: GitHub Pages へデプロイ(30秒)           │
│  ┌──────────────────────────────────────────┐           │
│  │ ダッシュボードの index.html を作成              │           │
│  │ レポートのマニフェスト manifest.json を生成       │           │
│  │ gh-pages ブランチへプッシュ                  │           │
│  │ ↓                                        │           │
│  │ 公開先: https://username.github.io/repo/│           │
│  └──────────────────────────────────────────┘           │
└────────────┬────────────────────────────────────────────┘
             │
             ▼
┌─────────────────────────────────────────────────────────┐
│  ジョブ6: 通知(5秒)                              │
│  ┌──────────────────────────────────────────┐           │
│  │ 全ジョブのステータスを確認                   │           │
│  │ レポートへのリンクをコミットへコメント       │           │
│  │ (任意: Slack に通知を送信)      │           │
│  └──────────────────────────────────────────┘           │
└─────────────────────────────────────────────────────────┘

総実行時間: 約5分(12分以上から短縮!)

ジョブの依存関係

ジョブは可能な限り 並列で実行されます

generate-data        (15秒)
    
    ├──→ test       (30秒) ──┐
    └──→ train      (2分)  ──┤
         ↓                  │
         generate-report(90秒)
         ↓                  │
         deploy-pages   (30秒)
         ↓                  │
         notify         (5秒) ←┘

重要な洞察: テストは学習の準備と並列で実行されます!

データフロー

生成からデプロイまで:

transactions_20260313_143522.csv
    ↓
[アーティファクトのアップロード]
    ↓
train_model.py
    ↓
classified_transactions.csv
metrics.json
ner_classifier.pkl
    ↓
[アーティファクトのアップロード]
    ↓
assessment_report.Rmd
    ↓
assessment_report_20260313_143522.html
    ↓
[アーティファクトのアップロード]
    ↓
GitHub Pages(gh-pages ブランチ)
    ↓
https://username.github.io/repo/

キャッシュ戦略の可視化

初回実行(コールドキャッシュ):
├─ Python パッケージ:    4分30秒  → キャッシュミス → ダウンロード&キャッシュ
├─ R パッケージ:         6分15秒  → キャッシュミス → ダウンロード&キャッシュ
├─ Pytest:             30秒     → キャッシュミス → 実行&キャッシュ
└─ 合計:              12分50秒

2回目以降(ウォームキャッシュ):
├─ Python パッケージ:    15秒     → キャッシュヒット → 復元
├─ R パッケージ:         20秒     → キャッシュヒット → 復元
├─ Pytest:             5秒      → キャッシュヒット → 復元
└─ 合計:              4分45秒

高速化: 2.7倍!

パフォーマンス指標: 前後比較

ビルド時間の比較

コンポーネント Before After 改善
Python セットアップ 4分30秒 15秒 18倍高速
R セットアップ 6分15秒 20秒 18.75倍高速
テスト実行 1分20秒 30秒 2.67倍高速
モデル学習 3分0秒 2分0秒 1.5倍高速
レポート生成 2分45秒 1分30秒 1.83倍高速
合計 12分50秒 4分35秒 2.8倍高速

コスト分析

Before:

12.85分 × 30回/月 = 385.5分/月
GitHub Actions: 月あたり 2,000分 無料枠
利用: 割当の 19.3%

After:

4.58分 × 30回/月 = 137.4分/月
GitHub Actions: 月あたり 2,000分 無料枠  
利用: 割当の 6.9%

効果: 無料枠内で 4.3倍多くのワークフローを実行できます!

キャッシュヒット率

本番利用から30日後:

キャッシュの種類 ヒット率 平均保存時間
Pythonパッケージ 95% 4m 15s
Rパッケージ 90% 5m 55s
Pytest 85% 25s
MLflowアーティファクト 80% 10s

キャッシュ全体の有効性: 91% のヒット率

リソース使用量

アーティファクトの保存:

変更前(圧縮なし):

├─ 取引データ: 500 KB × 30 = 15 MB
├─ モデルアーティファクト:  5 MB × 30 = 150 MB
├─ レポート:          8 MB × 30 = 240 MB
└─ 合計:                       405 MB/月

変更後(圧縮あり):

├─ 取引データ: 100 KB × 30 = 3 MB     (80%削減)
├─ モデルアーティファクト:  2 MB × 30 = 60 MB     (60%削減)
├─ レポート:          3 MB × 30 = 90 MB     (62%削減)
└─ 合計:                       153 MB/月(62%の総削減)

圧縮設定:

- uses: actions/upload-artifact@v4
  with:
    compression-level: 9  # 最大圧縮
    retention-days: 7     # 30から削減

ユーザーエクスペリエンス指標

指標 変更前 変更後 改善
最初のレポートまでの時間 15分 5分 3倍高速
ダッシュボードの読み込み時間 2.5秒 0.8秒 3.1倍高速
日付の表示 "Invalid" "3月13日" 修正しました!
レポートの鮮度 手動 自動 100%自動化

学んだこと

1. 攻めのキャッシュ、しかし無効化は慎重に

教訓: 実行の間で変わらないものはすべてキャッシュする。

ただし: 明確な無効化戦略を用意する。

# 良い例: セマンティックバージョニング
CACHE_VERSION: v1  # 新しいキャッシュが必要になったら更新する

# 良い例: ハッシュベースのキー
key: ${{ hashFiles('requirements.txt') }}

# 悪い例: 時間ベースのキー
key: cache-${{ github.run_number }}  # 一度もヒットしない!

犯したミス: 最初はバージョン番号なしでキャッシュしていた。パッケージが更新されたとき、古い依存関係(stale dependencies)を取得してしまった。

修正: CACHE_VERSION 環境変数を追加した。

2. すべてのタイムスタンプにISO 8601を使う

教訓: タイムスタンプには常にISO 8601形式を使う。

# 良い例
datetime.now().isoformat()  # "2026-03-13T14:35:22.123456"
# 悪い例
datetime.now().strftime('%Y%m%d_%H%M%S')  # "20260313_143522"

理由: ISO 8601 は以下の点で優れています:

  • どこでも解釈できる
  • 辞書順でソート可能
  • タイムゾーンに対応
  • JSONと相性が良い

これをしないコスト: 「Invalid Date」のデバッグに何時間もかかる!

3. 本番に近いデータでテストする

教訓: テストデータは静的に固定せず、動的に生成する。

変更前: テストではコミット済みの sample_data.csv を使用していた。
変更後: テストでは実行ごとに新しく生成されたデータを使用する

得られる利点:

  • エッジケースを見つけられる
  • データジェネレーターを検証できる
  • テストデータへの過学習を防げる

見つかった例のバグ:

# 静的データでは通っていた:
assert df['category'].nunique() == 8

# しかし生成データでは失敗(カテゴリは7つしか存在しない)
# 修正:
assert df['category'].nunique() >= 5  # 少なくとも5カテゴリ

4. 可能な場合は並列ジョブにする

教訓: 依存関係がボトルネックを作る。できるだけ並列化する。

変更前:

generate → test → train → report → deploy
(すべて逐次、12分)

変更後:

generate → test ─┐
          ├────→ train → report → deploy
          └────→ [その他のジョブ]
(可能な範囲で並列、5分)

ポイント: needs: を慎重に使う:

test:
  needs: [generate-data]  # データのためにのみ待つ

train-model:
  needs: [generate-data, test]  # 両方を待つ

5. 早く失敗し、明確に失敗する

教訓: テストが失敗したら、「なぜ」失敗したのかが一目で分かるようにする。

悪いエラーメッセージ:

AssertionError: assert False

良いエラーメッセージ:

assert category == "Groceries", \
    f"Expected 'Groceries', got '{category}'. " \
    f"Narration: '{text}', Confidence: {confidence}"

# Output:
# AssertionError: Expected 'Groceries', got 'Unknown'. 
# Narration: 'walmart shopping', Confidence: 0.0

これで分かりました:

  1. 何が失敗したか(カテゴリのアサーション)
  2. 期待値と実際の値
  3. 状況(ナレーション文)
  4. なぜ失敗したか(信頼度が0)

6. キャッシュの有効性を監視する

教訓: 時間の経過とともにキャッシュヒット率を追跡する。

ログを追加しました:

- name: Check cache status
  run: |
    if [ "${{ steps.cache.outputs.cache-hit }}" == "true" ]; then
      echo "✓ Cache hit!"
    else
      echo "✗ Cache miss - downloading packages"
    fi

監視する指標: キャッシュヒット率は >85% を維持する必要があります。

これより低い場合:

  • キャッシュキーが細かすぎる可能性があります
  • 依存関係が頻繁に変わりすぎている可能性があります
  • キャッシュサイズの上限に達している可能性があります

7. アーティファクトの保持期間を最適化する

教訓: 必要なものは残し、不要なものは削除する。

# Before: Everything kept 90 days
retention-days: 90

# After: Tiered retention
- Transaction data: 7 days   # Regenerable
- Model artifacts: 30 days   # Useful for comparison
- Reports: 90 days           # Want history

節約: ストレージコストを62%削減!

8. ドキュメントはコード

教訓: READMEはコードそのものと同じくらい重要です。

投資:

  • 包括的なREADMEを書くために2時間
  • デプロイガイドに30分
  • トラブルシューティング欄に1時間

見返り:

  • セットアップに関するサポート質問がゼロ
  • コントリビューターが<5分でオンボード可能
  • デプロイ関連の問題を90%削減

9. POCから始めて、本番へ反復的に進める

教訓: 最初からすべてを一気に作ろうとしない。

私たちの歩み:

  1. Week 1: 基本的な分類器(ルールベースのみ)
  2. Week 2: MLの強化を追加
  3. Week 3: 手動でのレポーティング
  4. Week 4: GitHub Actionsの自動化
  5. Week 5: キャッシュ&最適化を追加
  6. Week 6: UXを磨く、バグを修正

ポイント: 毎週、価値を追加する。「ビッグバン」リリースはしない。

10. すべてオープンソースにする

教訓: 公開することで品質が向上する。

オープンソースにする前:

  • ハードコードされたパス
  • ドキュメントなし
  • あちこちに雑なハック

オープンソースにした後:

  • 設定可能
  • よく整備されたドキュメント

- 本番対応コード

結論

やり遂げたこと

PoC(概念実証)から始めて、私たちは 本番レベルのMLパイプライン を構築しました。これは:

✅ インテリジェントなキャッシュで 3倍高速 に動作
✅ GitHub Actionsの無料ティアで 月額$0
新鮮なデータを自動生成

✅ レポートを自律的にWebへデプロイ
分類精度91.2% を達成
✅ 指導なしで新しいカテゴリを発見
✅ MLflowで完全なMLOps追跡を提供
87%のテストカバレッジ
✅ 人手なしで24/7稼働

数字

指標
パイプライン実行時間 4分35秒(12分50秒だった)
スピードアップ 2.8倍高速
コスト 月額$0
テストカバレッジ 87%
分類精度 91.2%
キャッシュヒット率 95%
コード行数 約3,500
デプロイまでの時間 < 5分

重要なポイント

  1. すべてキャッシュする - ヒット率95%で2.8倍の高速化
  2. ISO 8601を使う - デバッグにかかった時間を数時間節約
  3. 動的なデータ - 静的テストが見逃したバグを発見
  4. 早く失敗する - 明確なエラーが時間を節約
  5. しっかりドキュメントする - READMEはコードと同じくらい重要

技術スタック

言語&フレームワーク:

  • Python 3.9 (ML/NLP)
  • R 4.3 (統計/レポーティング)
  • YAML (設定)
  • Markdown (ドキュメント)

ML&データ:

  • scikit-learn (分類)
  • pandas (データ操作)
  • NLTK (テキスト処理)
  • MLflow (実験追跡)

DevOps:

  • GitHub Actions (CI/CD)
  • GitHub Pages (ホスティング)
  • Codecov (カバレッジ追跡)
  • Docker (将来のデプロイ)

可視化:

  • R Markdown (レポート)
  • Plotly (インタラクティブなチャート)
  • ggplot2 (静的なチャート)
  • DT (データテーブル)

リソース

ライブデモ:

ドキュメント:

  • README: セットアップの包括的なガイド
  • CI/CDガイド: ワークフローのカスタマイズ
  • APIドキュメント: 分類器の利用方法
  • コントリビューティング: 参加方法

連絡先:

- LinkedIn: https://www.linkedin.com/in/daniel-amah-2559a4159/

謝辞

使用技術:

  • たくさんのコーヒー ☕
  • 多くのデバッグセッション
  • すばらしいコミュニティからのフィードバック
  • MLOpsへの情熱

特別な感謝:

  • 無料のCI/CDを提供してくれたGitHub Actionsチーム
  • 素晴らしいツールを提供してくれたMLflowコミュニティ
  • 素晴らしいレポーティングをしてくれたR/RStudioチーム
  • scikit-learnの貢献者
  • フィードバックを寄せてくれたすべての方々

❤️ Python、R、MLflow、GitHub Actions、そしてたくさんの愛を使って制作

最終更新日: 2026年3月