旅行好きなソフトエンジニアの備忘録

マネージメントから現場に戻ることになり慌てて勉強しなおしている人です

画像AI開発の環境構築の流れのメモ

昔はpoetryを用いていたのですが、今はuvが便利と聞いたのでそのメモです。

uvのwindowsへのインストール

下記の方法でインストールできます。 [Python]Windows環境でuvを使った効率的な仮想環境構築!インストールからVS Codeでのデバッグまで|こはた

プロジェクトの作成

uv init project-name

venvの構築(python 3.11をインストールする例)

2026年7月時点、python 3.12以上を使用するとOpemMMLabのインストールで嵌りやすいそうです。

cd project-name
uv venv --python 3.11
uv pin python 3.11

pytorchのインストール(CUDA12.1の例)

pyproject.tomlのdependencies以降に以下を追記する

[project]
dependencies = [
    "torch",
    "torchvision"
]

[[tool.uv.index]]
name = "pytorch-cu121"
url = "https://download.pytorch.org/whl/cu121"
explicit = true

[tool.uv.sources]
torch = [{ index = "pytorch-cu121" }]
torchvision = [{ index = "pytorch-cu121" }]

[tool.ruff]
line-length = 120
select = ["E", "F", "W", "I", "B", "N", "UP", "SIM", "RUF"]

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

[tool.pyright]
typeCheckingMode = "basic"

編集後にuv syncを実行する

CI系ツールをインストールする

uv add --dev ruff
uv add --dev ruff-format
uv add --dev pyright
uv add --dev pytest
uv add --dev pytest-watch
uv add --dev pre-commit
uv add --dev mlflow

設定を変更する

Ctrl + Shift + PでVSCodeのsettings.jsonを開き以下を加える。これによりRuffがリアルタイムにコードを解析してくれる。

{
    "editor.formatOnSave": true,
    "editor.codeActionsOnSave": {
        "source.fixAll": true
    },
    "python.linting.enabled": false,
    "ruff.lint.run": "onType"
}

.precommit-config.yamlを作成し、以下を記載する。これによりgitにcommitする際にRuff, Pyright, 単体テストが走り、失敗するとcommitできないようになる。

repos:
    - repo: https://github.com/astral-sh/ruff-pre-commit
      rev: v0.13.0
      hooks:
          -  id: ruff
          -  id: ruff-format

    - repo: local
      hooks:
          -  id: pyright
             name: run pyright
             entry: uv run pyright
             language: system
             pass_filenames: false

          -  id: pytest
             name: run pytest
             entry: uv run pytest tests/
             language: system
             pass_filenames: false

その後に以下を実行する。

uv run pre-commit install

git commitを通すためのダミー単体テストを作成し、initial commitする

mkdir tests

test_dummy.pyを作成する

def test_dummy() -> None:
    assert True

画像AIの学会情報メモ

 

名称 概要 開催月
ファクトリーイノベーションWeek 製造業の課題解決をテーマに、スマート工場、ロボデックス、カーボンニュートラル、人手不足対策、安全・環境改善などの展示会で構成される総合展。

2月

9月

GTC NVIDIA主催のAI・アクセラレーテッドコンピューティング会議。GPU、AIインフラ、生成AI、フィジカルAI、ロボティクス、データセンター技術などを扱う。 3月
WACV コンピュータビジョンの応用寄りテーマに強い国際会議。実世界応用、産業応用、認識・検出・解析技術などを扱う。 3月
Embedded Vision Summit 組込みビジョン、エッジAI、実装・製品化に焦点を当てたカンファレンス&展示会。製品開発者、技術サプライヤー、エンジニア向け。 5月
SSII 画像センシング分野の学術シンポジウム。画像学術界と画像産業界の動向を扱い、研究者・技術者の議論の場として運営されている。 6月
CVPR コンピュータビジョン・パターン認識分野のトップ国際会議。画像認識、動画理解、3Dビジョン、生成AI、マルチモーダルなど広範な研究が集まる。 6月
MIRU 画像の認識・理解技術に関する国内最大規模の会議。大学・産業界の研究者、技術者、学生が、基礎から応用まで最新研究を発表・議論する場。

8月

AIMST 製造業・産業オートメーション向けのIndustrial AI会議。AI製造、SCADA、IT/OT、サイバーセキュリティ、デジタル変革などを扱う。

8月

ECCV 欧州系の主要コンピュータビジョン国際会議。偶数年開催で、CV・機械学習分野の研究発表、ワークショップ、チュートリアルが行われる。

9月

ViEW 画像処理・ビジョン技術の「実利用」に重点を置くワークショップ。外観検査を起点に、医療・農業・モビリティなど応用分野も広い。

12月

国際画像機器展 国内最大級のマシンビジョン・画像処理関連展示会。FA、ロボティクス、社会インフラ、セキュリティ、医療・ライフサイエンスなどの応用を扱う。 12月

 

 

cleanlab: アノテーションミス候補を提案してもらう

機械学習においては学習に使用するデータの量だけでなく質を担保することが非常に重要ということで、昔は自分で論文を参考にアノテーションミス候補のデータを絞るロジックを書いていたのですが、今なら何かライブラリがあるだろうと思いcleanlabというライブラリを見つけたので、そのライブラリの使い方を記す簡単なサンプルをcodexに作成してもらったのでメモします。

cleanlabにfind_label_issuesという関数があって、そこにアノテーションや推論によって出力された確率を渡してあげれば良いようです。 結果として"アノテーションミスではないか"と出力されたHTMLは以下の通りで、確かにアノテーションミスが疑われるケースを抽出できている。

cleanlabによってアノテーションミスと疑われた例

detect_mislabeled_samples.py

import numpy as np
import torch
from cleanlab.filter import find_label_issues
from sklearn.model_selection import StratifiedKFold
from torch.utils.data import DataLoader, Subset

from mnist_dataset import MNISTConfig, MNISTDataset
from train import train_one_fold
from visualizer import save_suspicious_html


def predict_probabilities(model, data_loader, device: str) -> np.ndarray:
    model.eval()
    probabilities = []

    with torch.no_grad():
        for images, _ in data_loader:
            images = images.to(device)
            logits = model(images)
            batch_probs = torch.softmax(logits, dim=1)
            probabilities.append(batch_probs.cpu().numpy())

    return np.concatenate(probabilities, axis=0)


def predict_all_data_with_kfold(
    dataset,
    labels: np.ndarray,
    n_splits: int,
    epochs: int,
    batch_size: int,
    learning_rate: float,
    device: str,
) -> np.ndarray:
    pred_probs = np.zeros((len(dataset), 10), dtype=np.float32)
    kfold = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=0)

    for fold, (train_indices, valid_indices) in enumerate(
        kfold.split(np.zeros(len(labels)), labels),
        start=1,
    ):
        print(f"fold={fold}/{n_splits} train={len(train_indices)} valid={len(valid_indices)}")

        train_subset = Subset(dataset, train_indices.tolist())
        valid_subset = Subset(dataset, valid_indices.tolist())
        valid_loader = DataLoader(valid_subset, batch_size=batch_size, shuffle=False)

        model = train_one_fold(
            train_subset=train_subset,
            batch_size=batch_size,
            epochs=epochs,
            learning_rate=learning_rate,
            device=device,
        )
        pred_probs[valid_indices] = predict_probabilities(model, valid_loader, device)

    return pred_probs


def detect_mislabeled_samples(
    top_n: int = 100,
    n_splits: int = 5,
    epochs: int = 10,
    learning_rate: float = 1e-3,
) -> dict[str, np.ndarray]:

    device = "cuda" if torch.cuda.is_available() else "cpu"
    
    # データセットを準備する
    mnist = MNISTDataset(MNISTConfig())
    dataset = mnist.train_dataset()
    labels = np.array(MNISTDataset.labels(dataset))

    # 学習->推論を実施し、各画像のクラス確率を取得する
    # pred_probsの形状は (num_images, num_classes) で、各画像が0-9のどのクラスに属するかの確率を表す
    pred_probs = predict_all_data_with_kfold(
        dataset=dataset,
        labels=labels,
        n_splits=n_splits,
        epochs=epochs,
        batch_size=mnist.config.batch_size,
        learning_rate=learning_rate,
        device=device,
    )

    # 各画像のクラス確率を用いて、cleanlabで疑わしいラベルを見つける
    ranked_indices = find_label_issues(
        labels=labels,
        pred_probs=pred_probs,
        return_indices_ranked_by="self_confidence",
    )
    suspicious_indices = ranked_indices[:top_n]

    return {
        "indices": suspicious_indices,
        "labels": labels,
        "pred_probs": pred_probs,
    }


if __name__ == "__main__":
    result = detect_mislabeled_samples()
    output_path = save_suspicious_html(
        indices=result["indices"],
        labels=result["labels"],
        pred_probs=result["pred_probs"],
    )
    print(f"saved visualization: {output_path}")

mnist_dataset.py

from dataclasses import dataclass

from torch.utils.data import DataLoader, Dataset, Subset
from torchvision import datasets, transforms


@dataclass(frozen=True)
class MNISTConfig:
    """Settings shared by training and label-issue detection."""

    data_dir: str = "data"
    batch_size: int = 128
    num_workers: int = 0
    train_limit: int | None = None
    test_limit: int | None = None


class MNISTDataset:
    """Small wrapper around torchvision's MNIST dataset.

    Keeping dataset setup here makes the other scripts focus on their own job:
    training, cleanlab scoring, or visualization.
    """

    def __init__(self, config: MNISTConfig | None = None):
        self.config = config or MNISTConfig()
        self.transform = transforms.Compose(
            [
                transforms.ToTensor(),
                transforms.Normalize((0.1307,), (0.3081,)),
            ]
        )

    def train_dataset(self) -> Dataset:
        dataset = datasets.MNIST(
            root=self.config.data_dir,
            train=True,
            download=True,
            transform=self.transform,
        )
        return self._limit(dataset, self.config.train_limit)

    def test_dataset(self) -> Dataset:
        dataset = datasets.MNIST(
            root=self.config.data_dir,
            train=False,
            download=True,
            transform=self.transform,
        )
        return self._limit(dataset, self.config.test_limit)

    def train_loader(self, shuffle: bool = True) -> DataLoader:
        return DataLoader(
            self.train_dataset(),
            batch_size=self.config.batch_size,
            shuffle=shuffle,
            num_workers=self.config.num_workers,
        )

    def test_loader(self) -> DataLoader:
        return DataLoader(
            self.test_dataset(),
            batch_size=self.config.batch_size,
            shuffle=False,
            num_workers=self.config.num_workers,
        )

    @staticmethod
    def labels(dataset: Dataset) -> list[int]:
        """Return labels for a Dataset or Subset without loading all images."""

        if isinstance(dataset, Subset):
            base_targets = dataset.dataset.targets
            return [int(base_targets[i]) for i in dataset.indices]
        return [int(label) for label in dataset.targets]

    @staticmethod
    def _limit(dataset: Dataset, limit: int | None) -> Dataset:
        if limit is None:
            return dataset
        return Subset(dataset, range(min(limit, len(dataset))))

simple_cnn.py

import torch
from torch import nn


class SimpleCNN(nn.Module):
    """Very small CNN used for a quick MNIST experiment."""

    def __init__(self, num_classes: int = 10):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 16, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(16, 32, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(2),
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(32 * 7 * 7, 64),
            nn.ReLU(),
            nn.Linear(64, num_classes),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        x = self.features(x)
        return self.classifier(x)


def make_model(num_classes: int = 10, device: str = "cpu"):
    return SimpleCNN(num_classes=num_classes).to(device)

train.py

from pathlib import Path

import torch
from torch import nn
from torch.utils.data import DataLoader

from mnist_dataset import MNISTConfig, MNISTDataset
from simple_cnn import make_model


CHECKPOINT_PATH = Path("simple_cnn_mnist.pt")


def accuracy(model: nn.Module, data_loader, device: str) -> float:
    model.eval()
    correct = 0
    total = 0

    with torch.no_grad():
        for images, labels in data_loader:
            images = images.to(device)
            labels = labels.to(device)
            predictions = model(images).argmax(dim=1)
            correct += (predictions == labels).sum().item()
            total += labels.numel()

    return correct / total


def train_one_fold(
    train_subset,
    batch_size: int,
    epochs: int,
    learning_rate: float,
    device: str,
) -> nn.Module:
    """Train a fresh model for one K-Fold split and return it."""

    model = make_model(device=device)
    optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
    loss_fn = nn.CrossEntropyLoss()
    train_loader = DataLoader(train_subset, batch_size=batch_size, shuffle=True)

    for _ in range(epochs):
        model.train()
        for images, labels in train_loader:
            images = images.to(device)
            labels = labels.to(device)

            optimizer.zero_grad()
            loss = loss_fn(model(images), labels)
            loss.backward()
            optimizer.step()

    return model


def train(
    epochs: int = 3,
    learning_rate: float = 1e-3,
    checkpoint_path: Path = CHECKPOINT_PATH,
) -> None:
    device = "cuda" if torch.cuda.is_available() else "cpu"
    mnist = MNISTDataset(MNISTConfig())
    train_loader = mnist.train_loader(shuffle=True)
    test_loader = mnist.test_loader()

    model = make_model(device=device)
    optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate)
    loss_fn = nn.CrossEntropyLoss()

    for epoch in range(1, epochs + 1):
        model.train()
        running_loss = 0.0

        for images, labels in train_loader:
            images = images.to(device)
            labels = labels.to(device)

            optimizer.zero_grad()
            logits = model(images)
            loss = loss_fn(logits, labels)
            loss.backward()
            optimizer.step()

            running_loss += loss.item() * labels.size(0)

        train_loss = running_loss / len(train_loader.dataset)
        test_accuracy = accuracy(model, test_loader, device)
        print(
            f"epoch={epoch} train_loss={train_loss:.4f} "
            f"test_accuracy={test_accuracy:.4f}"
        )

    torch.save(model.state_dict(), checkpoint_path)
    print(f"saved checkpoint: {checkpoint_path}")


if __name__ == "__main__":
    train()

visualizer.py

import base64
from io import BytesIO
from pathlib import Path

import numpy as np
from torchvision import datasets


def _image_to_base64(image) -> str:
    buffer = BytesIO()
    image.save(buffer, format="PNG")
    return base64.b64encode(buffer.getvalue()).decode("ascii")


def save_suspicious_html(
    indices,
    labels,
    pred_probs,
    output_path: str | Path = "suspects.html",
    data_dir: str = "data",
) -> Path:
    """Render cleanlab's suspicious MNIST examples as a small HTML gallery."""

    output_path = Path(output_path)
    raw_mnist = datasets.MNIST(root=data_dir, train=True, download=True)

    cards = []
    for rank, index in enumerate(indices, start=1):
        index = int(index)
        image, _ = raw_mnist[index]
        given_label = int(labels[index])
        predicted_label = int(np.argmax(pred_probs[index]))
        confidence_given_label = float(pred_probs[index][given_label])
        confidence_predicted_label = float(pred_probs[index][predicted_label])

        cards.append(
            f"""
            <article class="card">
              <div class="rank">#{rank} index={index}</div>
              <img src="data:image/png;base64,{_image_to_base64(image)}" alt="MNIST sample {index}">
              <dl>
                <dt>Given label</dt><dd>{given_label}</dd>
                <dt>Model guess</dt><dd>{predicted_label}</dd>
                <dt>P(given)</dt><dd>{confidence_given_label:.4f}</dd>
                <dt>P(guess)</dt><dd>{confidence_predicted_label:.4f}</dd>
              </dl>
            </article>
            """
        )

    document = f"""
    <!doctype html>
    <html lang="en">
    <head>
      <meta charset="utf-8">
      <title>MNIST cleanlab suspicious labels</title>
      <style>
        body {{
          margin: 0;
          font-family: Arial, sans-serif;
          background: #f4f6f8;
          color: #1f2933;
        }}
        header {{
          padding: 28px 32px 12px;
        }}
        h1 {{
          margin: 0;
          font-size: 28px;
        }}
        p {{
          margin: 8px 0 0;
          color: #52606d;
        }}
        main {{
          display: grid;
          grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
          gap: 16px;
          padding: 20px 32px 32px;
        }}
        .card {{
          border: 1px solid #d9e2ec;
          border-radius: 8px;
          background: white;
          padding: 14px;
        }}
        .rank {{
          font-size: 13px;
          color: #52606d;
          margin-bottom: 10px;
        }}
        img {{
          display: block;
          width: 112px;
          height: 112px;
          image-rendering: pixelated;
          margin: 0 auto 12px;
          border: 1px solid #bcccdc;
          background: #111827;
        }}
        dl {{
          display: grid;
          grid-template-columns: 1fr auto;
          gap: 6px 10px;
          margin: 0;
          font-size: 14px;
        }}
        dt {{
          color: #52606d;
        }}
        dd {{
          margin: 0;
          font-weight: 700;
        }}
      </style>
    </head>
    <body>
      <header>
        <h1>MNIST cleanlab suspicious labels</h1>
        <p>Low P(given) examples are likely annotation mistakes or ambiguous digits.</p>
      </header>
      <main>
        {"".join(cards)}
      </main>
    </body>
    </html>
    """

    output_path.write_text(document, encoding="utf-8")
    return output_path

github.com

cleanvision: 画像データの簡易品質チェックを行うライブラリ

画像AI開発における学習データの簡易品質チェックを実施したいと考えていたのですが、cleanvisionというライブラリがありました。 これを使用すると以下の全て、もしくは指定した項目に該当する画像を検出してもらえます。

  1. 重複した画像
  2. ほぼ同じ画像
  3. ボケた画像
  4. 情報の無い画像
  5. 暗すぎる画像
  6. 明るすぎる画像
  7. グレースケール画像
  8. アスペクト比異常画像
  9. サイズ異常画像

github.com

Datasheets for Datasets

収集したデータセットの文書化に関する論文。自身が収集したデータセットを他の人が利用することを想定し、他の人にとってデータセットを理解できるものにするため何を記録しておくべきか確認したいという動機で確認した。

Motivation

データセットが作成された背景

  • 何の目的で作成されたのか
  • 誰が作成したのか

Composition

データセットに何が含まれているか

  • 各データが何を表すか
  • 機密情報や個人情報が含まれているか

Collection Process

データがどのように収集されたか

  • 誰からどのようにデータを集めたのか
  • 同意は取られているか
  • どんな手段(センサー、スクレイピング、etc)で取得したか
  • 収集時期や地域

Preprocessing

データを公開する前にどのような処理をしているか

  • データの削除、補完等の有無
  • ラベル付けの方法
  • ラベルの品質確認の実施有無

Uses

データセットの想定用途と非推奨用途

  • どんなタスクに使う想定か
  • 商用利用できるか
  • 医療等の高リスク領域で使用して良いか
  • 使うべきでない用途があるか

Distribution

データセットがどのように配布されるか

  • どこで入手できるか
  • ライセンスは何か
  • 利用制限はあるか
  • 再配布可能か

Maintenance

データセットが今後どのように管理されるか

  • 更新予定があるか
  • 誤りがあった場合に修正されるか
  • 問い合わせ先があるか

arxiv.org

画像AIプロジェクト 実践ハンドブックを読んでの備忘録

久々に画像AIプロジェクトを担当することになったため、復習のため表題の本を読み自身にとってのポイントをメモします。 書籍は自身にとって振り返りに良いものでしたが、画像AIプロジェクト未経験者が読んでも記載がサラッとしており理解は難しいと思われます。 また、参考文献が記載されていないことはマイナスでした。 例えば合成データは実データ8-9割+合成データ1-2割でブレンドすることで最も高い精度向上が得られると記載があるのですが、それに対する文献の記載が無い。最先端の学術会議よりも先進的な企業が「どうやって現場にAIをデプロイしたか」を語るテックブログやカンファレンスに注目するとあるのですが、具体的な照会が無いといった部分です。

第一章

  • モデルはSOTAにこだわらない
  • 研究での評価は固定された公開データセットで行われ、現場データと乖離があることが多い
  • 加えてSOTAモデルは巨大な計算資源を必要とすることがある
  • 精度、速度、運用コストといった複数の観点でモデルを選定する
  • Papers with Codeは閉鎖されており、モデル情報収集のハブはHugging Faceとなっている

第二章

  • 開発はPython/PyTorchで実施、本番環境へのデプロイはONNX等の共通規格を介して推論環境に最適化するという二段構えパイプラインを初期段階から想定する
  • 物体検出における標準はUltralytics社のYOLOv8、YOLOv11(AGPL-3.0のためライセンスに注意)
  • ライセンスが問題になるならYOLOXやRT-DETRは代表的な代替となる
  • インスタンスセグメンテーションが必要であればDetectron2
  • アノテーション効率化に有用なモデルとしてSAMがある

第三章

  • アノテーションデータのフォーマットは様々なものがある。実務におけるベストプラクティスは情報量の多いCOCO形式で一元化し、学習を実行するパイプラインでYOLO形式への変換等を行う
  • 暗黙知を言語化してアノテーション仕様書を作成する。特にエッジケースの定義、バウンディングボックスの引き方等アノテータでバラツキが出ないようにする
  • 代表的なアノテーションツールとしてCVAT、LabelStudioがある

第四章

  • 特にメモしたい点なし

第五章

  • 最新すぎるモデルを採用した場合、ONNXがサポートしている標準的なオペレータに変換できないリスクがでる
  • デプロイメントパイプラインはPyTorchモデルをONNX変換⇒量子化/最適化の実行⇒推論
  • 推論サーバーではDynamic Batching / 複数モデルの同時実行 / 無停止でのモデルアップデートをサポートしているTriton Inference Serverの導入が必要
  • モデルを管理するため、実験管理としてMLFlowやW&B、データバージョン管理としてDVCがある

第六章

  • リリースしたモデルを更新するかの判定にはゴールドデータセットへの推論結果で判断する

第七章

  • モデルの精度が劣化する原因はデータドリフト、コンセプトドリフトがある
  • 劣化を監視する方法として確信度の分布変化、人間が介在する率の上昇などがある
  • 再学習するデータを選定するアプローチにActive Learningがある

【PyTorch】Macro Soft F1 Lossを実装する

マルチラベル問題の評価指標の一つにMacro F1というものがあります。 Macro F1はそのままでは微分できないのでロス関数には適さないのですが、評価指標を微分可能にしてロス関数にしてしまおうという考えもあるようです。

towardsdatascience.com

リンクではMacro F1をロス関数に適用出来るようにしたMacro Soft F1 LossのKeras実装があるのですが、PyTorch版を実装しました。sigmoid + binary crossentropyと比較するとロスと正解率が結びつきやすいのは利点ですが、バッチサイズを十分に取らないといけなさそうなロス関数という印象です。

import torch
import torch.nn as nn


class MacroSoftF1Loss(nn.Module):
    def __init__(self, consider_true_negative, sigmoid_is_applied_to_input):
        super(MacroSoftF1Loss, self).__init__()
        self._consider_true_negative = consider_true_negative
        self._sigmoid_is_applied_to_input = sigmoid_is_applied_to_input

    def forward(self, input_, target):
        target = target.float()
        if self._sigmoid_is_applied_to_input:
            input = input_
        else:
            input = torch.sigmoid(input_)
        TP = torch.sum(input * target, dim=0)
        FP = torch.sum((1 - input) * target, dim=0)
        FN = torch.sum(input * (1 - target), dim=0)
        F1_class1 = 2 * TP / (2 * TP + FP + FN + 1e-8)
        loss_class1 = 1 - F1_class1
        if self._consider_true_negative:
            TN = torch.sum((1 - input) * (1 - target), dim=0)
            F1_class0 = 2*TN/(2*TN + FP + FN + 1e-8)
            loss_class0 = 1 - F1_class0
            loss = (loss_class0 + loss_class1)*0.5
        else:
            loss = loss_class1
        macro_loss = loss.mean()
        return macro_loss