オープンウェイトでマルチモーダルな System One Models は画像分類に使えるか?

前回の記事では、Jev ライクな意思決定モデル(System One Models)を Zero-Shot のテキスト分類器として使い、ハイパーパラメータをチューニングした TF-IDF + Logistic Regression とほぼ互角の精度が出ることを確かめました。

System One Models の中には、テキストだけでなく画像を入力できるマルチモーダルなモデルもあります。「この写真に写っているのは何か」を choice 型の質問として投げれば、そのまま Zero-Shot の画像分類器として使えるはずです。ただ、画像の Zero-Shot 分類には CLIP という定番のベースラインが存在します。学習データを一切使わない条件で比べたとき、System One Models が CLIP に対してどこまで通用するのかは気になるところです。

この記事では、LiquidAI の d1-3B を llama-server 上で動かし、CIFAR-10 の画像を Zero-Shot で分類して open_clip の CLIP ViT-B/32 と比較します。あわせて、有名なベンチマークで高い精度が出たときに疑うべき「テスト画像の暗記(データリーク)」の影響を CIFAR-10.1 で切り分け、さらに実運用でボトルネックになりやすい推論速度の差までを検証します。

System One Models で画像を分類する

/v1/systemone に画像を渡す

llama-server の /v1/systemone エンドポイントは、状況を表す state と型付きの質問(questions)を受け取り、各質問に対する判定結果を確率つきで返します。画像を入力する場合は、images フィールドに data URI 形式で渡します。

{
  "state": "Photo attached to a pet-sitting request.",
  "images": ["data:image/jpeg;base64,..."],
  "questions": {
    "pet":  {"type": "choice", "instructions": "Which animals are in the photo?",
             "criteria": {"cats": "Cats", "dogs": "Dogs", "birds": "Birds"}},
    "sofa": {"type": "noul", "instructions": "Are the animals on a sofa?"}
  }
}

choice 型の質問に対しては、criteria に定義した候補の中から選ばれたラベル(choice)、候補ごとの確率分布(probabilities)、および確信度(confidence)が返ってきます。自由記述のテキストを生成するのではなく、候補ラベルの確率だけを直接計算する仕組みなので、チャット型の VLM に質問して返答の文字列からラベルを抽出するよりもはるかに扱いやすく、後続の処理にそのまま組み込めます。

画像分類に使う場合は、criteria にクラス名とその短い説明文を並べるだけで Zero-Shot 分類器になります。

CLIP の Zero-Shot 分類

比較相手の CLIP(Contrastive Language-Image Pre-training)は、画像とテキストを同じベクトル空間へ埋め込むように対照学習されたモデルです。「a photo of a cat.」のようにクラス名を入れたテキストをあらかじめ埋め込んでおき、入力画像の埋め込みベクトルと最もコサイン類似度が高いクラスを予測ラベルとします。画像の Zero-Shot 分類における代表的な手法であり、今回の比較基準としてうってつけです。

今回は open_clip の ViT-B-32(学習済み重みは laion2b_s34b_b79k)を使います。パラメータ数は約 1.5 億(151M)とコンパクトで、CPU でも実用的な速度で動作します。

検証環境

項目内容
OSWindows 11 Pro
CPUAMD Ryzen AI 9 HX 370
GPUAMD Radeon 890M(iGPU / 32GB)
メモリ96 GB
llama-serverVulkan 版(version 0.7.0-dev, build 11813)
uv0.13.6
Python3.13
主なライブラリtorch 2.15.0 / open-clip-torch 3.3.0 / datasets 5.1.0 / httpx 0.28.1 / scikit-learn 1.9.1

d1-3B は GGUF 形式(F16)のモデルファイルを使用します。画像を入力するには、言語モデル本体に加えて、画像を埋め込み表現へ変換するビジョンプロジェクタ(mmproj)のファイルが必要です。

推論時は Vulkan 版の llama-server を使って d1-3B の全レイヤーを iGPU にオフロードし、比較対象の CLIP は CPU 上で動かします。

ファイルサイズ
d1-3B-F16.gguf5.4 GB
mmproj-d1-3B-F16.gguf0.85 GB

環境構築

uv でプロジェクトを作成

前回と同じく、uv に --package を付けて src レイアウトのプロジェクトを作成します。

$ uv init --package --name systemone-imgclf systemone-imgclf
$ cd systemone-imgclf
$ uv add httpx tqdm pillow numpy datasets scikit-learn open_clip_torch torch

最終的なディレクトリ構成は次のとおりです。

systemone-imgclf/
├── pyproject.toml
├── src/systemone_imgclf/
│   ├── data.py      # データ読み込み・テストサンプル抽出・ラベル説明文
│   ├── server.py    # llama-server の起動と停止
│   ├── zeroshot.py  # /v1/systemone クライアント
│   ├── clip_zs.py   # CLIP による Zero-Shot 分類
│   └── metrics.py   # 評価指標
├── scripts/
│   ├── run_systemone.py
│   ├── run_clip.py
│   └── compare.py   # 比較表の作成
├── data/            # CIFAR-10.1(後述)
└── results/

llama-server を評価スクリプトから起動する

今回は評価スクリプトの実行中だけ llama-server を子プロセスとして起動し、評価が終わったら自動で停止する形にしました。画像入力のために --mmproj を指定する必要があり、普段常駐させているサーバーとは設定を分けておきたかったためです。

なお、Python から llama.cpp を直接呼び出せる llama-cpp-python も検討しましたが、現時点では /v1/systemone に相当する API が提供されていません。候補ごとの確率計算を自前で実装すると、測定結果が「d1-3B 本来の実力」なのか「自作した推論処理の出来」なのか切り分けられなくなるため、公式のエンドポイントを持つ llama-server を使うことにしました。server.py の実装は次のとおりです。

"""評価中だけ llama-server(Vulkan 版)を起動・停止する。"""

import subprocess
import time
from pathlib import Path

import httpx

LLAMA_DIR = Path(r"C:\llama-cpp")
SERVER_EXE = LLAMA_DIR / "vulkan" / "llama-server.exe"
MODEL_DIR = Path(r"C:\models\LiquidAI\d1-3B-GGUF")
MODEL = MODEL_DIR / "d1-3B-F16.gguf"
MMPROJ = MODEL_DIR / "mmproj-d1-3B-F16.gguf"


class LlamaServer:
    """with 文で使う。ブロックを抜けるとサーバーを停止する。"""

    def __init__(self, log_path: Path, port: int = 8080, ctx: int = 8192, startup_timeout: float = 300.0):
        self.port = port
        self.log_path = log_path
        self.startup_timeout = startup_timeout
        self.args = [
            str(SERVER_EXE), "-m", str(MODEL), "--mmproj", str(MMPROJ),
            "-ngl", "99", "-c", str(ctx), "-np", "1",
            "--host", "127.0.0.1", "--port", str(port),
        ]
        self.proc: subprocess.Popen | None = None

    @property
    def base_url(self) -> str:
        return f"http://127.0.0.1:{self.port}"

    def __enter__(self) -> "LlamaServer":
        if self._healthy():
            raise RuntimeError(f"ポート {self.port} で別のサーバーが動いています")
        self.log_path.parent.mkdir(exist_ok=True)
        self._log = self.log_path.open("w", encoding="utf-8")
        self.proc = subprocess.Popen(self.args, stdout=self._log, stderr=subprocess.STDOUT)
        deadline = time.monotonic() + self.startup_timeout
        while time.monotonic() < deadline:
            if self.proc.poll() is not None:
                self._log.close()
                raise RuntimeError(f"llama-server が終了しました。ログ: {self.log_path}")
            if self._healthy():
                return self
            time.sleep(1)
        self.__exit__(None, None, None)
        raise TimeoutError(f"llama-server が {self.startup_timeout:.0f} 秒以内に起動しませんでした")

    def __exit__(self, *exc) -> None:
        if self.proc and self.proc.poll() is None:
            self.proc.terminate()
            try:
                self.proc.wait(timeout=30)
            except subprocess.TimeoutExpired:
                self.proc.kill()
        self._log.close()

    def _healthy(self) -> bool:
        try:
            # モデル読み込み中は 503 が返る
            return httpx.get(f"{self.base_url}/health", timeout=2).status_code == 200
        except httpx.HTTPError:
            return False

/health エンドポイントはモデルの読み込み中に 503 を返すため、ステータスコードが 200 になるまでポーリングしてから評価を開始します。リクエストは 1 件ずつ順番に送るので、並列スロット数は -np 1 に絞りました。起動時間は、初回がモデルの読み込みで約 17 秒、OS のファイルキャッシュが効く 2 回目以降は約 3 秒でした。

評価の設計

データセット:CIFAR-10 から 1,000 件を層化抽出する

最初の実験には CIFAR-10 を使います。飛行機・自動車・鳥・猫・鹿・犬・カエル・馬・船・トラックの 10 クラスからなる、32×32 ピクセルのカラー画像データセットです。データは Hugging Face の uoft-cs/cifar10 から取得します。

テストデータは全体で 10,000 件ありますが、iGPU での推論時間を考慮して、各クラス 100 件ずつ、計 1,000 件を層化抽出しました。抽出したインデックスは JSON ファイルに保存し、d1-3B と CLIP でまったく同じ 1,000 件を評価します。サンプル数が 1,000 件の場合、Accuracy 0.95 前後における 95% 信頼区間はおおよそ ±1.5 ポイントになります。

ラベルの説明文

d1-3B の criteria には、クラスごとに短い英語の説明文を渡します。automobile と truck のように意味が近いクラスは、モデルが境界を判断しやすいように説明文で区別をつけました。後半の実験 2 で使う cifar10.1 の読み込み処理もあわせて、data.py にまとめています。

"""CIFAR-10 の読み込みと、全手法で共有するテストサンプルの管理。"""

import json
from pathlib import Path

import numpy as np
from datasets import load_dataset as hf_load_dataset
from PIL import Image

PROJECT_DIR = Path(__file__).resolve().parents[2]
RESULTS_DIR = PROJECT_DIR / "results"
SEED = 42

LABEL_NAMES = ["airplane", "automobile", "bird", "cat", "deer",
               "dog", "frog", "horse", "ship", "truck"]

# Zero-Shot 用に各クラスの内容を短く説明したもの(criteria に渡す)
LABEL_DESCRIPTIONS = {
    "airplane": "An airplane or other aircraft",
    "automobile": "A car, sedan, or other passenger road vehicle (not a truck)",
    "bird": "A bird",
    "cat": "A cat",
    "deer": "A deer",
    "dog": "A dog",
    "frog": "A frog or toad",
    "horse": "A horse",
    "ship": "A ship, boat, or other watercraft",
    "truck": "A truck, lorry, or other large cargo vehicle",
}

DATA_DIR = PROJECT_DIR / "data"
DATASETS = ["cifar10", "cifar10.1"]


def load_test(dataset: str = "cifar10") -> tuple[list[Image.Image], list[int]]:
    if dataset == "cifar10":
        ds = hf_load_dataset("uoft-cs/cifar10", split="test")
        return ds["img"], ds["label"]
    if dataset == "cifar10.1":
        # https://github.com/modestyachts/CIFAR-10.1 の v6(2,000 件、各クラス 200 件。ラベル番号は CIFAR-10 と同じ)
        d = DATA_DIR / "cifar10.1"
        x = np.load(d / "cifar10.1_v6_data.npy", allow_pickle=False)
        y = np.load(d / "cifar10.1_v6_labels.npy", allow_pickle=False)
        return [Image.fromarray(a) for a in x], y.tolist()
    raise ValueError(f"unknown dataset: {dataset}")


def sample_test_indices(labels: list[int], per_class: int, dataset: str = "cifar10") -> list[int]:
    """クラスごとに per_class 件ずつ抽出する。結果はファイルに保存し、全手法で同じものを使う。"""
    path = RESULTS_DIR / f"sample_indices_{dataset}_{per_class}.json"
    if path.exists():
        return json.loads(path.read_text(encoding="utf-8"))
    rng = np.random.default_rng(SEED)
    labels = np.asarray(labels)
    idx = []
    for c in range(len(LABEL_NAMES)):
        idx += rng.choice(np.flatnonzero(labels == c), per_class, replace=False).tolist()
    idx = sorted(int(i) for i in idx)
    RESULTS_DIR.mkdir(exist_ok=True)
    path.write_text(json.dumps(idx), encoding="utf-8")
    return idx

/v1/systemone クライアント

/v1/systemone を呼び出す zeroshot.py は、前回のテキスト分類で使ったクライアントをほぼそのまま流用しています。変更点は、PIL 画像を PNG の data URI に変換して images 配列に渡す部分だけです。

"""llama-server の /v1/systemone を使った Zero-Shot 画像分類。"""

import base64
import io
import json
import time
from pathlib import Path

import httpx
from PIL import Image

STATE = "A small photo from an image dataset."
INSTRUCTIONS = "Which object category is shown in the photo?"


def to_data_uri(img: Image.Image, size: int) -> str:
    # CIFAR-10 は 32x32 なので、ビジョンエンコーダに渡す前に拡大しておく
    if size and img.size != (size, size):
        img = img.resize((size, size), Image.Resampling.BICUBIC)
    buf = io.BytesIO()
    img.convert("RGB").save(buf, format="PNG")
    return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")


class SystemOneClient:
    def __init__(self, base_url: str, criteria: dict[str, str], timeout: float = 300.0):
        self.criteria = criteria
        self.endpoint = f"{base_url}/v1/systemone"
        self.http = httpx.Client(timeout=timeout)

    def classify(self, image_uri: str) -> dict:
        body = {
            "state": STATE,
            "images": [image_uri],
            "questions": {
                "object": {
                    "type": "choice",
                    "instructions": INSTRUCTIONS,
                    "criteria": self.criteria,
                }
            },
        }
        r = self.http.post(self.endpoint, json=body)
        if r.status_code != 200:
            raise RuntimeError(f"HTTP {r.status_code}: {r.text[:300]}")
        data = r.json()
        ans = data["answers"]["object"]
        return {
            "choice": ans["choice"],
            "confidence": ans.get("confidence"),
            "probabilities": ans.get("probabilities"),
            "input_tokens": data.get("usage", {}).get("input_tokens"),
        }


def load_done(path: Path) -> dict[int, dict]:
    done = {}
    if path.exists():
        for line in path.read_text(encoding="utf-8").splitlines():
            if line.strip():
                rec = json.loads(line)
                done[rec["idx"]] = rec
    return done


def run(client: SystemOneClient, items: list[tuple[int, Image.Image, str]], out_path: Path,
        size: int, retries: int = 3, progress=None) -> None:
    """items: (テストデータのインデックス, 画像, 正解ラベル名)。結果は JSONL に追記し、再実行時は続きから処理する。"""
    done = load_done(out_path)
    # エラーで終わった件は再実行時にやり直す(同じ idx は後勝ちで読み込まれる)
    todo = [it for it in items if it[0] not in done or "error" in done[it[0]]]
    out_path.parent.mkdir(exist_ok=True)
    with out_path.open("a", encoding="utf-8") as f:
        for idx, img, label in (progress(todo) if progress else todo):
            rec = {"idx": idx, "label": label}
            uri = to_data_uri(img, size)
            for attempt in range(retries):
                start = time.perf_counter()
                try:
                    rec.update(client.classify(uri))
                    rec["latency"] = time.perf_counter() - start
                    rec.pop("error", None)
                    break
                except Exception as e:  # noqa: BLE001 - 失敗は記録して続行する
                    rec["error"] = str(e)
                    time.sleep(2 * (attempt + 1))
            f.write(json.dumps(rec, ensure_ascii=False) + "\n")
            f.flush()

実装上のポイントは 3 つあります。

  • CIFAR-10 の元画像は 32×32 と小さいため、PIL のバイキュービック補間で 224×224 に拡大してから渡しています。
  • state には「画像データセットの小さな写真」とだけ書き、正解クラスのヒントになるような情報は入れていません。
  • 結果は 1 件ずつ JSONL に追記し、途中で中断しても再実行すれば未処理のサンプルから再開できるようにしています。

なお、latency は classify() の前後で計測しているため、HTTP リクエストの送受信と JSON パースの時間を含みます(画像の拡大と base64 エンコードは計測区間外です)。

CLIP による Zero-Shot 分類

比較用の CLIP は clip_zs.py に実装しました。テキストプロンプトはシンプルに「a photo of a {クラス名}.」の 1 種類だけを使い、前計算したテキスト特徴量と画像特徴量の内積(ロジット)に softmax をかけて各クラスの確率を求めています。

"""open_clip による Zero-Shot 分類(比較用ベースライン)。"""

import json
import time
from pathlib import Path

import open_clip
import torch
from PIL import Image

MODEL = "ViT-B-32"
PRETRAINED = "laion2b_s34b_b79k"
TEMPLATE = "a photo of a {}."


class ClipZeroShot:
    def __init__(self, labels: list[str]):
        self.labels = labels
        self.model, _, self.preprocess = open_clip.create_model_and_transforms(MODEL, pretrained=PRETRAINED)
        self.model.eval()
        tokenizer = open_clip.get_tokenizer(MODEL)
        with torch.no_grad():
            t = self.model.encode_text(tokenizer([TEMPLATE.format(l) for l in labels]))
            self.text_features = t / t.norm(dim=-1, keepdim=True)

    @torch.no_grad()
    def classify(self, img: Image.Image) -> dict:
        x = self.preprocess(img.convert("RGB")).unsqueeze(0)
        f = self.model.encode_image(x)
        f = f / f.norm(dim=-1, keepdim=True)
        probs = (100.0 * f @ self.text_features.T).softmax(dim=-1)[0].tolist()
        best = max(range(len(probs)), key=probs.__getitem__)
        return {
            "choice": self.labels[best],
            "confidence": probs[best],
            "probabilities": dict(zip(self.labels, probs)),
        }


def run(clf: ClipZeroShot, items: list[tuple[int, Image.Image, str]], out_path: Path, progress=None) -> None:
    out_path.parent.mkdir(exist_ok=True)
    with out_path.open("w", encoding="utf-8") as f:
        for idx, img, label in (progress(items) if progress else items):
            start = time.perf_counter()
            rec = {"idx": idx, "label": label, **clf.classify(img)}
            rec["latency"] = time.perf_counter() - start
            f.write(json.dumps(rec) + "\n")

d1-3B と条件を揃えるため、CLIP もバッチ推論は行わず 1 枚ずつ順番に処理してレイテンシを記録します。計測区間には preprocess による前処理(224×224 へのリサイズと正規化)も含めています。また、CLIP 側の confidence には、softmax 後の最大確率をそのまま格納しました。

各手法の比較には、次の 4 つの指標を使います。

4 つ目の「confidence 上位 X% の正解率」は、前回の記事と同様に、confidence を自動処理と人手確認の振り分けしきい値として使えるかを確かめるための指標です。metrics.py の実装は次のとおりです。

指標内容
Accuracy全体の正解率。あわせて Wilson の方法で 95% 信頼区間を計算する
Macro-F1クラスごとの F1 スコアの単純平均(今回は各クラスのサンプル数が均等なため Accuracy とほぼ同じ値になる)
ECE(Expected Calibration Error)予測クラスの確率を 10 区間に分け、各区間の「平均確率」と「実際の正解率」の差をサンプル数で重み付け平均した値。0 に近いほど確率の較正(キャリブレーション)が取れている
confidence 上位 X% の正解率confidence の高い順に上位 X% のサンプルだけを採用したときの正解率(カバレッジ別の精度)
"""評価指標。"""

import json
import math
from pathlib import Path

import numpy as np
from sklearn.metrics import accuracy_score, confusion_matrix, f1_score


def load_results(path: Path) -> list[dict]:
    recs = {}
    for line in path.read_text(encoding="utf-8").splitlines():
        if line.strip():
            rec = json.loads(line)
            recs[rec["idx"]] = rec  # 再試行した件は後勝ち
    return [r for r in recs.values() if "error" not in r]


def wilson_ci(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
    p = k / n
    denom = 1 + z**2 / n
    center = (p + z**2 / (2 * n)) / denom
    half = z * math.sqrt(p * (1 - p) / n + z**2 / (4 * n**2)) / denom
    return center - half, center + half


def summarize(recs: list[dict], labels: list[str]) -> dict:
    y_true = [r["label"] for r in recs]
    y_pred = [r["choice"] for r in recs]
    k = sum(t == p for t, p in zip(y_true, y_pred))
    lo, hi = wilson_ci(k, len(recs))
    return {
        "n": len(recs),
        "accuracy": accuracy_score(y_true, y_pred),
        "acc_ci": (lo, hi),
        "macro_f1": f1_score(y_true, y_pred, labels=labels, average="macro"),
        "per_class_f1": dict(zip(labels, f1_score(y_true, y_pred, labels=labels, average=None))),
        "confusion": confusion_matrix(y_true, y_pred, labels=labels),
        "latency_ms": 1000 * float(np.mean([r["latency"] for r in recs])),
    }


def ece(recs: list[dict], bins: int = 10) -> float:
    """選んだクラスの確率(最大確率)を信頼度とした Expected Calibration Error。"""
    conf = np.array([max(r["probabilities"].values()) for r in recs])
    correct = np.array([r["label"] == r["choice"] for r in recs], dtype=float)
    edges = np.linspace(0, 1, bins + 1)
    total = 0.0
    for lo, hi in zip(edges[:-1], edges[1:]):
        m = (conf > lo) & (conf <= hi)
        if m.any():
            total += m.mean() * abs(conf[m].mean() - correct[m].mean())
    return float(total)


def coverage_accuracy(recs: list[dict], fractions=(0.25, 0.5, 0.75, 1.0)) -> dict[float, float]:
    """confidence の高い順に上位 fraction だけ採用したときの精度。"""
    s = sorted(recs, key=lambda r: r["confidence"] or 0.0, reverse=True)
    out = {}
    for fr in fractions:
        top = s[: max(1, round(len(s) * fr))]
        out[fr] = float(np.mean([r["label"] == r["choice"] for r in top]))
    return out

CIFAR-10 で精度を比べる

実行

d1-3B を評価する scripts/run_systemone.py では、LlamaServer のコンテキスト内でサーバーを起動し、ウォームアップ用のリクエストを 1 回投げてから本計測に入ります。初回リクエスト時に発生する初期化のオーバーヘッドを推論時間に含めないためです。

"""d1-3B(/v1/systemone)で層化抽出したテスト画像を Zero-Shot 分類する。

評価中だけ llama-server を起動し、終わったら停止する。
"""

import argparse

from tqdm import tqdm

from systemone_imgclf.data import DATASETS, LABEL_DESCRIPTIONS, LABEL_NAMES, RESULTS_DIR, load_test, sample_test_indices
from systemone_imgclf.server import LlamaServer
from systemone_imgclf.zeroshot import SystemOneClient, run, to_data_uri


def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("--dataset", choices=DATASETS, default="cifar10")
    p.add_argument("--per-class", type=int, default=30, help="クラスごとに抽出するテスト件数")
    p.add_argument("--limit", type=int, help="動作確認用に先頭から処理する件数")
    p.add_argument("--size", type=int, default=224, help="拡大後の画像サイズ(0 で拡大しない)")
    p.add_argument("--port", type=int, default=8080)
    p.add_argument("--tag", default="d1-3B", help="出力ファイル名に付ける名前")
    args = p.parse_args()

    images, labels = load_test(args.dataset)
    idx = sample_test_indices(labels, args.per_class, args.dataset)
    if args.limit:
        idx = idx[: args.limit]
    items = [(i, images[i], LABEL_NAMES[labels[i]]) for i in idx]
    out = RESULTS_DIR / f"systemone_{args.tag}_{args.dataset}_s{args.size}_n{args.per_class}.jsonl"

    with LlamaServer(RESULTS_DIR / "llama-server.log", port=args.port) as server:
        client = SystemOneClient(server.base_url, LABEL_DESCRIPTIONS)
        client.classify(to_data_uri(items[0][1], args.size))  # 初回の遅延を計測に含めないため
        run(client, items, out, size=args.size, progress=lambda xs: tqdm(xs, desc=args.tag))
    print(f"saved: {out}")


if __name__ == "__main__":
    main()

CLIP 側の実行スクリプト(scripts/run_clip.py)も同じ構成なのでコードは省略します。まずは --limit 5 で疎通を確認したうえで、各クラス 100 件(計 1,000 件)の評価を実行しました。

$ uv run scripts/run_systemone.py --limit 5   # 動作確認
$ uv run scripts/run_systemone.py --per-class 100
$ uv run scripts/run_clip.py --per-class 100
$ uv run scripts/compare.py

動作確認の時点で d1-3B の推論時間は 1 枚あたり約 0.55 秒と予想より速かったため、当初予定していた各クラス 30 件から 100 件へ増やしています。1,000 件全体の所要時間は d1-3B が約 8 分 50 秒、CLIP が約 40 秒で、いずれもエラーなく完走しました。

結果:d1-3B が CLIP を約 5 ポイント上回る

手法Accuracy(95% CI)Macro-F1ECE
CLIP ViT-B/320.934(0.917–0.948)0.9340.024
d1-3B0.982(0.972–0.989)0.9820.027

d1-3B の Accuracy は 0.982 に達し、CLIP ViT-B/32(0.934)を 4.8 ポイント上回りました。誤分類の件数で見ると、CLIP の 66 件に対して d1-3B は 18 件と 3 分の 1 以下に減っています。95% 信頼区間も重なっておらず、サンプル抽出の揺らぎを超えた明確な差が出ています。

なお、CLIP の 0.934 という数字は、open_clip が公式ベンチマークで公開している同モデルの CIFAR-10 Zero-Shot 精度(0.9358)とほぼ一致しており、評価パイプライン自体の妥当性も確認できました。

クラスごとの結果

続いて、クラス別の F1 スコアを見てみます。

クラスCLIPd1-3B
airplane0.970.99
automobile0.960.99
bird0.910.97
cat0.890.96
deer0.910.98
dog0.910.97
frog0.930.99
horse0.940.98
ship0.961.00
truck0.950.98

10 クラスすべてで d1-3B が CLIP を上回りました。特に差が開いたのは cat・deer・bird・dog といった動物系のクラスです。混同行列を確認すると、CLIP では「猫→犬」(7 件)、「鹿→馬」(7 件)、「犬→猫」(5 件)のように動物同士の取り違えが目立ちます。一方、d1-3B で複数件発生した誤りは「トラック→自動車」の 3 件くらいで、残りは 1〜2 件ずつに分散していました。

confidence と正解率の連動

confidence の高い順に上位 X% のサンプルだけを採用したときの正解率は次のようになりました。

採用する割合CLIPd1-3B
上位 25%1.0001.000
上位 50%0.9981.000
上位 75%0.9930.999
全件0.9340.982

両手法とも confidence が高い領域ほど正解率がきれいに上がっており、ECE もそろって 0.03 未満に収まっています。とくに d1-3B は、上位 75%(750 件)まで採用しても誤分類がわずか 1 件(正解率 99.9%)しかありません。テキスト分類のときと同様に、confidence がしきい値以上のものだけ自動処理し、迷っているサンプルだけを人手に回す運用がそのまま組めそうです。

CIFAR-10.1 でテスト画像の暗記を検証する

高すぎる精度の要因を切り分ける

Zero-Shot で 98.2% というのはかなり高い数字です。ここで一つ疑っておきたいのが、事前学習データへのテスト画像の混入(データリーク / データ汚染)です。CIFAR-10 は 2009 年の公開以来、Web 上の解説記事やリポジトリに数え切れないほど転載されています。もし d1-3B の学習データに CIFAR-10 のテスト画像(あるいは酷似した画像)が含まれていたとすれば、純粋な画像認識の汎化性能ではなく、「見たことがある画像を記憶していた」ために正解できた可能性が出てきます。

d1-3B の学習データは非公開なので、直接照合することはできません。そこで、CIFAR-10 と同じ手順・同じ分布で作られたが、CIFAR-10 のテスト画像そのものではないデータセットで再評価し、精度の低下幅を CLIP と比較することで、暗記の影響を切り分けることにしました。

CIFAR-10.1 とは

CIFAR-10.1(Recht et al., 2018)は、CIFAR-10 の元データである Tiny Images から、当時と同じ収集手順で新たに抜き出して作られたテスト専用データセットです。10 クラス・32×32 ピクセルという仕様は共通で、各クラス 200 件の計 2,000 件で構成されています。原論文では、CIFAR-10 で学習・評価された既存の多くの画像分類モデルが、CIFAR-10.1 では数ポイントから十数ポイント精度を落とすことが報告されています。つまり、テスト画像を一切見ていない通常のモデルであっても、分布のわずかなズレによってある程度は精度が下がるデータセットです。

この性質を踏まえて、次の基準で判断します。

  • もし d1-3B の精度低下が CLIP より大幅に大きいなら、CIFAR-10 のテスト画像を過度に記憶していた疑いが強い
  • 精度の低下幅が CLIP と同程度かそれより小さいなら、CIFAR-10 での 98% は汎化性能を反映したものとみなせる

データセットは GitHub リポジトリから v6 の .npy ファイル 2 つを取得し、data/cifar10.1/ に配置しました。

New-Item -ItemType Directory -Force data\cifar10.1
foreach ($f in "cifar10.1_v6_data.npy", "cifar10.1_v6_labels.npy") {
  Invoke-WebRequest "https://github.com/modestyachts/CIFAR-10.1/raw/master/datasets/$f" -OutFile "data\cifar10.1\$f"
}

ラベルの番号体系は CIFAR-10 と同一なので、先ほど data.py に用意した読み込み分岐だけでそのまま動きます。今回は 2,000 件すべてを評価対象としました。

$ uv run scripts/run_systemone.py --dataset cifar10.1 --per-class 200
$ uv run scripts/run_clip.py --dataset cifar10.1 --per-class 200
$ uv run scripts/compare.py

scripts/compare.py には、CIFAR-10(1,000 件)と CIFAR-10.1(2,000 件)の精度差を算出する関数を追加しています。2 つのデータセットは独立したサンプル集合なので、差の 95% 信頼区間は独立 2 標本の比率の差に対する正規近似で計算しました。

def drop_table(files: list[Path]) -> list[str]:
    """CIFAR-10 → CIFAR-10.1 の精度の下がり幅(独立 2 標本の正規近似で 95% CI)。"""
    acc: dict[str, dict[str, tuple[float, int]]] = {}
    for path in files:
        recs = load_results(path)
        if recs:
            method, ds = split_name(path.stem)
            acc.setdefault(method, {})[ds] = (sum(r["label"] == r["choice"] for r in recs) / len(recs), len(recs))
    lines = ["\n## CIFAR-10 → CIFAR-10.1 の精度低下\n",
             "| 手法 | CIFAR-10 | CIFAR-10.1 | 差 (95% CI) |", "|---|---:|---:|---|"]
    for method, d in acc.items():
        if {"cifar10", "cifar10.1"} <= d.keys():
            (p1, n1), (p2, n2) = d["cifar10"], d["cifar10.1"]
            half = 1.96 * math.sqrt(p1 * (1 - p1) / n1 + p2 * (1 - p2) / n2)
            diff = p2 - p1
            lines.append(f"| {method} | {p1:.3f} | {p2:.3f} | {diff:+.3f} ({diff - half:+.3f}〜{diff + half:+.3f}) |")
    return lines

結果:d1-3B の低下幅は CLIP より小さい

手法CIFAR-10CIFAR-10.1差(95% CI)
CLIP ViT-B/320.9340.903−3.1 pt(−5.1〜−1.0)
d1-3B0.9820.970−1.2 pt(−2.4〜−0.1)

どちらのモデルも CIFAR-10.1 で精度を落としましたが、d1-3B の低下幅は −1.2 ポイントにとどまり、CLIP(−3.1 ポイント)よりも小さいという結果になりました。誤分類率(エラー率)の倍率で見ても、d1-3B が 1.8% → 3.0%(約 1.7 倍)、CLIP が 6.6% → 9.7%(約 1.5 倍)とほぼ同水準であり、d1-3B だけが不自然に崩れるような挙動は見られません。

クラス別の内訳を見ても、d1-3B は CIFAR-10.1 の全 10 クラスで F1 スコア 0.94 以上を維持しています。誤分類の傾向も CIFAR-10 のときと大きく変わらず、猫と犬の混同(猫→犬が 7 件、犬→猫が 5 件)、船→飛行機(5 件)、自動車→トラック(4 件)が主な内訳です。一方の CLIP は、猫→犬(17 件)、鹿→馬(16 件)、自動車→トラック(15 件)など、やはり動物クラスを中心に誤分類が増えていました。

また、confidence による絞り込みも引き続き有効で、CIFAR-10.1 においても d1-3B の上位 50% では正解率 100%、上位 75% で 99.7% を記録しています。

少なくともこの比較からは、d1-3B が CIFAR-10 のテスト画像を特別に丸暗記していたとは考えにくく、98% 前後という高い精度はモデル自体の認識性能による部分が大きいと言えそうです。

推論速度を比べる

1 枚あたりの処理時間

精度面では d1-3B が優位でしたが、実運用を考えると推論速度は無視できません。両モデルともバッチサイズ 1 で 1 枚ずつ処理したときのレイテンシ統計をまとめると、次のようになります。

手法実行環境データ平均中央値p95最大合計
CLIP ViT-B/32CPUCIFAR-10(1,000 件)38 ms38 ms44 ms51 ms38 秒
CLIP ViT-B/32CPUCIFAR-10.1(2,000 件)42 ms42 ms45 ms48 ms83 秒
d1-3BiGPUCIFAR-10(1,000 件)524 ms524 ms543 ms618 ms8.7 分
d1-3BiGPUCIFAR-10.1(2,000 件)476 ms464 ms503 ms1,211 ms15.9 分

推論速度では、CPU 実行の CLIP が iGPU 実行の d1-3B より 12〜14 倍高速でした。30 億パラメータ規模のモデルを iGPU に載せても、約 1.5 億パラメータの ViT-B/32 には速度面でまったく歯が立ちません。

念のため、tqdm の経過時間から算出した全体の平均(CLIP は 38 秒 ÷ 1,000 件 = 38 ms、d1-3B は約 530 秒 ÷ 1,000 件 = 530 ms)とも突き合わせましたが、1 枚ごとの計測平均とほぼ一致しています。なお、CPU 上の PyTorch は 1 枚の画像推論を 12 スレッドで並列計算していますが、複数枚の画像をバッチ処理しているわけではないため、40 ms 前後という数字が純粋な 1 枚あたりのレイテンシです。

d1-3B の入力トークン数は、拡大後の画像トークンと質問文を合わせて毎回 177 トークンで固定でした。/v1/systemone は出力トークンを逐次生成しないため、推論時間はほぼこの 177 トークンの prefill(順伝播)だけで決まります。レイテンシのばらつきも小さく、CIFAR-10.1 における最大値 1,211 ms は単発のスパイクでした。2 つのデータセット間で平均値が 50 ms ほど異なるのは、連続実行時のサーマルスロットリングやバックグラウンドプロセスの影響と考えられます。

精度と速度のトレードオフ

ここまでの結果を、リソース消費と精度・速度の観点で整理します。

項目CLIP ViT-B/32d1-3B
モデルファイルのサイズ約 0.6 GB約 6.3 GB
CIFAR-10 での誤分類数(1,000 件あたり)66 件18 件
CIFAR-10.1 での誤分類数(1,000 件あたり)97 件30 件
1,000 件の処理時間約 40 秒約 8〜9 分

1,000 件あたり 8 分ほどの追加コストを払えば、誤分類を約 3 分の 1 に減らせるというトレードオフです。大量の画像を高速にさばきたい場面では CLIP、処理件数が限られていて少しでも取りこぼしを減らしたい場面では d1-3B と、向き不向きがはっきり分かれます。あるいは、1 段目で高速な CLIP を走らせ、confidence が低い微妙な画像だけを 2 段目の d1-3B にエスカレートするカスケード構成も現実的な選択肢になります。

改良版の CLIP ならどうか

なお、今回ベースラインに使った ViT-B/32 は、CLIP 系モデルの中では初期の軽量な構成です。open_clip が公開しているベンチマーク一覧(openclip_results.csv)を見ると、より大きなバックボーンや高品質なデータセットで学習された後発モデルは、CIFAR-10 の Zero-Shot でさらに高い精度を達成しています。

モデル学習データパラメータ数計算量(GFLOPs)CIFAR-10
ViT-B-32(今回使用)laion2b151M14.80.936
ViT-B-16dfn2b150M41.10.967
ViT-L-14openai428M1750.956
ViT-L-14datacomp_xl428M1750.982
ViT-L-14-quickgeludfn2b428M1750.984
EVA02-L-14merged2b428M1750.991
ViT-H-14-quickgeludfn5b986M3820.988

同じ ViT-L-14 アーキテクチャでも、初期の openai 版が 95.6% であるのに対し、フィルタリングでデータの質を高めた DataComp や DFN(Data Filtering Networks)で学習したモデルでは 98% を超えています。モデルのサイズ以上に、事前学習データの品質が Zero-Shot 精度を強く左右していることが分かります。とくに EVA02-L-14 は 428M パラメータで 99.1% に達しており、今回の d1-3B(98.2%)を上回ります。

もっとも、ViT-L-14 クラスの計算量(175 GFLOPs)は ViT-B/32(14.8 GFLOPs)の約 12 倍です。FLOPs 比から単純に試算すると、CPU 実行では 1 枚あたり 0.4〜0.5 秒程度かかる計算になり、iGPU 上の d1-3B と大差ない速度帯に入ってきます(今回は未実測)。また、公式ベンチマークの数値はテストデータ全 10,000 件に対し複数プロンプトのアンサンブルで測定されたものなので、単一プロンプト・1,000 件で測った今回の結果とは前提条件が少し異なる点も割り引いて見る必要があります。

過信してはいけないポイント

事前学習データとの重複は完全には否定できない

CIFAR-10.1 を使った検証によって、「CIFAR-10 のテスト画像だけを特別に記憶していた」という可能性は低いことが分かりました。ただし、CIFAR-10 も CIFAR-10.1 も、元をたどれば同じ Tiny Images から抽出されたデータです。もし d1-3B(あるいは LAION や DataComp で学習された CLIP 系モデル)の事前学習コーパスに Tiny Images 由来の画像が広く含まれていた場合、今回の比較だけではその影響を検出できません。データリークのない純粋な未知データでの実力を測るには、業務で実際に発生する独自撮影の画像などで検証する必要があります。

プロンプトとクラス説明文は 1 パターンしか試していない

今回の実験では、d1-3B に渡す state・instructions・criteria も、CLIP に渡すテキストテンプレートも、それぞれ 1 パターンずつしか試していません。説明文の書き方を変えれば精度が変動する可能性があります。とくに CLIP は「a blurry photo of a {}.」など複数のプロンプトテンプレートの埋め込みを平均する(プロンプトアンサンブル)ことで精度が向上することが知られており、今回の CLIP のスコアにはまだ改善の余地が残っています。

32×32 の低解像度画像を拡大して評価している

CIFAR-10 の画像は 32×32 ピクセルと非常に小さく、今回はバイキュービック補間で 224×224 に拡大してモデルに入力しました。拡大サイズや補間アルゴリズムによって精度やトークン数がどう変わるかは未検証です。また、実務で扱う画像は最初から高解像度であることが多いため、ボケた低解像度画像に強いモデルと、細部のディテールを読み取るモデルとで優劣が入れ替わる可能性もあります。

実行デバイスと量子化の条件が揃っていない

速度の比較では、CLIP を CPU、d1-3B を iGPU(F16)で動かしています。CLIP はバッチ推論や GPU 実行にすればさらに桁違いに速くなりますし、逆に d1-3B も Q8_0 や Q4_K_M などの量子化モデルを使えばメモリ帯域のボトルネックが緩和されて高速化できるはずです。今回のレイテンシは、あくまで「手元の PC で 1 枚ずつ F16 / FP32 推論を行った場合の目安」として捉えてください。

おわりに

System One Models の 1 つである LiquidAI の d1-3B を Zero-Shot の画像分類器として使い、CIFAR-10 および CIFAR-10.1 上で CLIP ViT-B/32 と比較しました。

一番の収穫は、学習データを一切使わない Zero-Shot 設定でありながら、d1-3B が CIFAR-10 で 98.2%、CIFAR-10.1 でも 97.0% と、CLIP ViT-B/32 を大きく引き離す精度を出せることを手元の実験で確かめられた点です。CIFAR-10.1 での精度低下も 1.2 ポイントにとどまっており、有名ベンチマークのテスト画像を丸暗記していたことによる見かけ上の高精度ではなさそうです。さらに confidence と実際の正解率がきれいに連動しているため、上位 75% を自動処理に回しても誤分類が 0.1〜0.3% 程度に収まるという、実務で使いやすい性質も確認できました。

その一方で、1 枚あたりの推論時間は CLIP より 12〜14 倍遅く、モデルサイズも約 10 倍あります。また、DataComp や DFN で学習された後発の CLIP(ViT-L-14 や EVA02-L-14)であれば、d1-3B と同程度の計算時間で同等以上の分類精度を出せる見込みです。「画像を 1 つのカテゴリに分類するだけ」のタスクであれば、依然として CLIP 系のモデルが第一候補になります。

それでも d1-3B に独自の魅力があるのは、criteria の説明文を書き換えるだけで分類軸を自在に変えられる柔軟さに加えて、1 回のリクエストの中で「どのカテゴリか」(choice)、「特定の条件を満たすか」(noul)、「重要度はどの程度か」(score)といった異なる型の質問をまとめて判定できる点です。次は単なるクラス分類から一歩進めて、画像に対する複数の判定を組み合わせた、より実務に近いタスクで試してみるつもりです。

More Information