System One Models / 意思決定モデルはテキスト分類に使えるか?

サポートチケットの振り分けや問い合わせのカテゴリ判定など、業務システムにおける判定・分岐処理の代表格といえばテキスト分類です。この分野には、学習データから単語の重みを学ぶ TF-IDF + Logistic Regression という、シンプルながら強力なベースラインが存在します。

前回の記事では、Ollama から使える Jev ライクな意思決定モデル(System One Models)を紹介しました。「このチケットはどのチームの担当か」といった型付きの判定を、チャット型の LLM よりずっと軽く、確率と確信度(confidence)つきで返してくれるモデルです。そこで次に気になるのが、「実際の分類タスクでどの程度の精度が出るのか」という点です。学習データを一切使わない Zero-Shot の System One Models が、古典的な教師あり機械学習モデルにどこまで迫れるのかは、実務への導入を判断するうえで押さえておきたいところです。

この記事では、scikit-learn 同梱の 20 Newsgroups データセットを使い、次の 2 つのアプローチを同じテストデータで比較します。

  • Optuna でハイパーパラメータをチューニングした TF-IDF + Logistic Regression
  • Ollama 経由で導入した kev / tev1 / d1-omni による Zero-Shot 分類

さらに後半では、各モデルの confidence と正答率の関係を整理したうえで、複数の System One Models を組み合わせる「ハイブリッド」によって精度や推論時間を改善できるかどうかも検証します。

先に結論

  • kev は Zero-Shot でありながら、チューニング済み Logistic Regression とほぼ互角でした(Accuracy 0.684 vs 0.689)。
  • ただし、話題がはっきりしたクラスには強く、似たクラス同士の区別には弱いという、得意・不得意が明確に分かれます。
  • confidence は正答率そのものではありませんが、高い・低いの目安としてはよく機能します(kev で confidence 0.8 以上なら正答率 96%)。
  • 複数の System One Models を組み合わせても精度はほとんど上がりません。モデル同士が同じ文書で一緒に間違えるためです。
  • 組み合わせる相手としては、Logistic Regression のほうが有望です。

検証環境

項目内容
OSWindows 11 Pro
GPUNVIDIA GeForce RTX 4060 Laptop GPU(VRAM 8GB)
Ollama0.40.6
uv0.13.3
Python3.14
主なライブラリscikit-learn 1.9.1 / optuna 5.0.0 / httpx 0.28.1 / scipy 1.18.1

対象モデルは次の 3 つです。

モデルサイズ(ollama list)
kev5.6 GB
tev14.6 GB
d1-omni849 MB

環境構築

モデルの準備と接続確認

まずは Ollama でモデルを取得します。

$ ollama pull kev
$ ollama pull tev1
$ ollama pull d1-omni

接続確認には、 /v1/systemone エンドポイントに PowerShell からリクエストを投げます。

$body = @'
{
  "model": "kev",
  "state": "The package arrived with a cracked screen.",
  "questions": {
    "route": {
      "type": "choice",
      "instructions": "Which team should handle this message?",
      "criteria": {
        "returns": "Damaged items and returns",
        "shipping": "Delivery and tracking",
        "billing": "Payments and charges"
      }
    }
  }
}
'@
Invoke-RestMethod -Uri "http://localhost:11434/v1/systemone" -Method Post `
  -ContentType "application/json; charset=utf-8" -Body $body | ConvertTo-Json -Depth 6

次のように choice と各候補の probabilities、confidence が返ってくれば OK です。

{
  "model": "kev",
  "answers": {
    "route": {
      "type": "choice",
      "choice": "returns",
      "probabilities": {
        "shipping": 0.16216671916474285,
        "returns": 0.8264779310293261,
        "billing": 0.011355349805931062
      },
      "confidence": 0.7397168965439891
    }
  },
  "usage": { "input_tokens": 31, "output_tokens": 0 }
}

uv でプロジェクトを作成

続いて、uv でプロジェクトを作ります。--package を付けて src レイアウトにしておくと、スクリプトから自作モジュールを素直に import できます。

$ uv init --package --name systemone-textclf systemone-textclf
$ cd systemone-textclf
$ uv add scikit-learn optuna httpx tqdm scipy

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

systemone-textclf/
├── pyproject.toml
├── src/systemone_textclf/
│   ├── data.py        # データ読み込み・テストサンプル抽出・ラベル説明文
│   ├── baseline.py    # TF-IDF + Logistic Regression と Optuna
│   ├── zeroshot.py    # /v1/systemone クライアント
│   └── metrics.py     # 評価指標
├── scripts/
│   ├── run_baseline.py
│   ├── run_zeroshot.py
│   ├── compare.py     # 比較表の作成
│   └── hybrid.py      # ハイブリッド分析
└── results/

評価の設計

データセット

データには scikit-learn の fetch_20newsgroups で取得できる 20 Newsgroups(全 20 クラス) を使います。ニュースグループへの投稿を、投稿先のグループに分類するタスクです。

ヘッダー・署名・引用部分には投稿先のヒントが含まれていてリークになるため、remove=("headers", "footers", "quotes") で取り除きます。学習データは 11,314 件、テストデータは 7,532 件です。

テストデータは 1,000 件に絞る

System One Models は 1 件あたり 0.1〜0.7 秒ほどかかります。テストデータ 7,532 件を 3 モデル分処理すると、それなりの時間になります。そこで、テストデータからクラス比率を保ったまま 1,000 件を層化抽出し、全手法で同じ 1,000 件を使って比較することにしました。

1,000 件だと、Accuracy 0.68 前後での 95% 信頼区間はおおよそ ±3 ポイントです。1 ポイント未満の差は「誤差の範囲」と読むのが妥当です。

ラベルの説明文

Zero-Shot 分類では、criteria に各ラベルの説明を渡す必要があります。comp.sys.ibm.pc.hardware のようなグループ名だけでは意味が伝わりにくいので、20 クラスそれぞれに短い英語の説明文を用意しました。ここは Zero-Shot の精度を左右する重要なポイントなので、後ほど改めて触れます。

data.py の全体は次のとおりです。

抽出したインデックスは results/test_sample_idx.json に保存し、どのスクリプトからも同じ 1,000 件を参照するようにしています。

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

import json
from dataclasses import dataclass
from pathlib import Path

import numpy as np
from sklearn.datasets import fetch_20newsgroups
from sklearn.model_selection import train_test_split

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

# Zero-Shot 用に各ニュースグループの内容を短く説明したもの(criteria に渡す)
LABEL_DESCRIPTIONS: dict[str, str] = {
    "alt.atheism": "Atheism, religion criticism, existence of God debates",
    "comp.graphics": "Computer graphics, image formats, 3D rendering software",
    "comp.os.ms-windows.misc": "Microsoft Windows operating system, drivers, Windows software issues",
    "comp.sys.ibm.pc.hardware": "IBM PC compatible hardware, motherboards, disks, IDE/SCSI controllers",
    "comp.sys.mac.hardware": "Apple Macintosh hardware, Mac models, monitors and peripherals",
    "comp.windows.x": "X Window System, X11, Motif, Unix GUI programming",
    "misc.forsale": "Items for sale, classified ads, buying and selling goods",
    "rec.autos": "Cars, automobiles, driving, car maintenance and dealers",
    "rec.motorcycles": "Motorcycles, bikes, riding, helmets",
    "rec.sport.baseball": "Baseball, MLB teams, players, pitching and batting",
    "rec.sport.hockey": "Ice hockey, NHL teams, players and playoffs",
    "sci.crypt": "Cryptography, encryption, Clipper chip, privacy and key escrow",
    "sci.electronics": "Electronics, circuits, components, electrical engineering",
    "sci.med": "Medicine, health, diseases, doctors and treatments",
    "sci.space": "Space, astronomy, NASA, spacecraft and orbits",
    "soc.religion.christian": "Christianity, the Bible, churches and Christian faith",
    "talk.politics.guns": "Gun control, firearms, the Second Amendment, weapons laws",
    "talk.politics.mideast": "Middle East politics, Israel, Palestine, Arabs, Armenia and Turkey",
    "talk.politics.misc": "General US politics, government, taxes, policy debates",
    "talk.religion.misc": "Miscellaneous religion discussion, cults, morality, non-Christian beliefs",
}


@dataclass
class Split:
    texts: list[str]
    labels: np.ndarray


@dataclass
class Dataset:
    train: Split
    test: Split
    target_names: list[str]


def load_dataset() -> Dataset:
    remove = ("headers", "footers", "quotes")
    tr = fetch_20newsgroups(subset="train", remove=remove)
    te = fetch_20newsgroups(subset="test", remove=remove)
    assert set(tr.target_names) == set(LABEL_DESCRIPTIONS)
    return Dataset(
        train=Split(list(tr.data), np.asarray(tr.target)),
        test=Split(list(te.data), np.asarray(te.target)),
        target_names=list(tr.target_names),
    )


def sample_test_indices(labels: np.ndarray, n: int = 1000) -> list[int]:
    """テストデータから層化抽出したインデックスを返す。初回に保存し、以後は再利用する。"""
    if SAMPLE_IDX_PATH.exists():
        saved = json.loads(SAMPLE_IDX_PATH.read_text())
        if saved["n"] == n:
            return saved["indices"]
    idx, _ = train_test_split(
        np.arange(len(labels)), train_size=n, stratify=labels, random_state=SEED
    )
    indices = sorted(int(i) for i in idx)
    RESULTS_DIR.mkdir(exist_ok=True)
    SAMPLE_IDX_PATH.write_text(json.dumps({"n": n, "seed": SEED, "indices": indices}))
    return indices

ベースライン:TF-IDF + Logistic Regression

Optuna でチューニングする

ベースラインは TfidfVectorizer と LogisticRegression をつないだ Pipeline です。Optuna の TPE サンプラーで、学習データ上の 5-fold CV の macro-F1 が最大になるパラメータを探します。テストデータはチューニングに一切使いません。

"""TF-IDF + Logistic Regression ベースライン(Optuna でチューニング)。"""

import time

import optuna
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline

from .data import SEED


def build_pipeline(params: dict) -> Pipeline:
    return Pipeline([
        ("tfidf", TfidfVectorizer(
            ngram_range=(1, params["ngram_max"]),
            min_df=params["min_df"],
            max_df=params["max_df"],
            sublinear_tf=params["sublinear_tf"],
            max_features=params["max_features"],
            stop_words="english" if params["stop_words"] else None,
        )),
        ("clf", LogisticRegression(C=params["C"], max_iter=2000)),
    ])


def suggest_params(trial: optuna.Trial) -> dict:
    return {
        "C": trial.suggest_float("C", 1e-2, 1e2, log=True),
        "ngram_max": trial.suggest_int("ngram_max", 1, 2),
        "min_df": trial.suggest_int("min_df", 1, 5),
        "max_df": trial.suggest_float("max_df", 0.3, 1.0),
        "sublinear_tf": trial.suggest_categorical("sublinear_tf", [True, False]),
        "max_features": trial.suggest_categorical("max_features", [None, 50_000, 100_000, 200_000]),
        "stop_words": trial.suggest_categorical("stop_words", [True, False]),
    }


def tune(texts, labels, n_trials: int, n_jobs: int = -1) -> optuna.Study:
    cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=SEED)

    def objective(trial: optuna.Trial) -> float:
        pipe = build_pipeline(suggest_params(trial))
        return cross_val_score(pipe, texts, labels, cv=cv, scoring="f1_macro", n_jobs=n_jobs).mean()

    study = optuna.create_study(direction="maximize", sampler=optuna.samplers.TPESampler(seed=SEED))
    study.optimize(objective, n_trials=n_trials, show_progress_bar=True)
    return study


def fit_best(texts, labels, params: dict) -> tuple[Pipeline, float]:
    pipe = build_pipeline(params)
    start = time.perf_counter()
    pipe.fit(texts, labels)
    return pipe, time.perf_counter() - start

実行用スクリプトでは、最良パラメータで学習データ全体を使って学習し直し、テストデータ全件と抽出した 1,000 件の両方で評価します。

"""TF-IDF + Logistic Regression をチューニング・評価して results/baseline.json に保存する。"""

import argparse
import json
import time

from systemone_textclf.baseline import fit_best, tune
from systemone_textclf.data import RESULTS_DIR, load_dataset, sample_test_indices
from systemone_textclf.metrics import summarize


def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("--trials", type=int, default=60)
    p.add_argument("--n", type=int, default=1000, help="層化抽出するテスト件数")
    args = p.parse_args()

    ds = load_dataset()
    study = tune(ds.train.texts, ds.train.labels, n_trials=args.trials)
    print(f"best CV macro-F1: {study.best_value:.4f}\nbest params: {study.best_params}")

    model, fit_sec = fit_best(ds.train.texts, ds.train.labels, study.best_params)

    start = time.perf_counter()
    pred_full = model.predict(ds.test.texts)
    pred_sec = time.perf_counter() - start

    idx = sample_test_indices(ds.test.labels, args.n)
    full = summarize(ds.test.labels, pred_full, ds.target_names)
    sample = summarize(ds.test.labels[idx], pred_full[idx], ds.target_names)

    result = {
        "method": "TF-IDF + LogisticRegression",
        "best_params": study.best_params,
        "cv_macro_f1": study.best_value,
        "n_trials": args.trials,
        "fit_sec": fit_sec,
        "latency_per_doc": pred_sec / len(ds.test.texts),
        "full_test": full,
        "sample_test": sample,
        "sample_pred": {int(i): ds.target_names[int(pred_full[i])] for i in idx},
    }
    RESULTS_DIR.mkdir(exist_ok=True)
    (RESULTS_DIR / "baseline.json").write_text(json.dumps(result, indent=2, ensure_ascii=False))
    print(f"full test  : acc={full['accuracy']:.4f} macro-F1={full['macro_f1']:.4f} (n={full['n']})")
    print(f"sample test: acc={sample['accuracy']:.4f} macro-F1={sample['macro_f1']:.4f} (n={sample['n']})")


if __name__ == "__main__":
    main()

評価指標をまとめる metrics.py はシンプルです。calibration_bins は、後で confidence と正答率の関係を見るのに使います。

"""評価指標の計算。"""

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


def summarize(y_true, y_pred, target_names: list[str]) -> dict:
    labels = list(range(len(target_names)))
    return {
        "n": int(len(y_true)),
        "accuracy": float(accuracy_score(y_true, y_pred)),
        "macro_f1": float(f1_score(y_true, y_pred, labels=labels, average="macro", zero_division=0)),
        "report": classification_report(
            y_true, y_pred, labels=labels, target_names=target_names,
            output_dict=True, zero_division=0,
        ),
    }


def calibration_bins(confidence, correct, n_bins: int = 5) -> list[dict]:
    """confidence の区間ごとに件数と正解率を返す。"""
    confidence = np.asarray(confidence, dtype=float)
    correct = np.asarray(correct, dtype=bool)
    edges = np.linspace(0.0, 1.0, n_bins + 1)
    bins = []
    for lo, hi in zip(edges[:-1], edges[1:]):
        mask = (confidence >= lo) & ((confidence < hi) if hi < 1.0 else (confidence <= hi))
        bins.append({
            "range": f"{lo:.1f}-{hi:.1f}",
            "n": int(mask.sum()),
            "accuracy": float(correct[mask].mean()) if mask.any() else None,
        })
    return bins

実行結果

手元の環境(24 コア CPU)では、60 トライアルの実行に 1 時間ほどかかりました。

テストデータ全件で macro-F1 は 0.686 でした。ヘッダーなどを除去した 20 Newsgroups としては一般的な水準です。抽出した 1,000 件でもほぼ同じ値なので、抽出に大きな偏りはなさそうです。

$ uv run scripts/run_baseline.py --trials 60
best CV macro-F1: 0.7518
best params: {'C': 11.201778336589168, 'ngram_max': 1, 'min_df': 1, 'max_df': 0.5122459533078412, 'sublinear_tf': True, 'max_features': 200000, 'stop_words': True}
full test  : acc=0.6953 macro-F1=0.6856 (n=7532)
sample test: acc=0.6890 macro-F1=0.6788 (n=1000)

Zero-Shot 分類:System One Models

/v1/systemone クライアント

Ollama の /v1/systemone には httpx で直接リクエストを送ります。質問タイプは 20 択なので choice です。

"""Ollama の /v1/systemone を使った Zero-Shot 分類。"""

import json
import time
from pathlib import Path

import httpx

ENDPOINT = "http://localhost:11434/v1/systemone"
INSTRUCTIONS = "Which Usenet newsgroup was this post published in?"


class SystemOneClient:
    def __init__(self, model: str, criteria: dict[str, str], timeout: float = 300.0):
        self.model = model
        self.criteria = criteria
        self.http = httpx.Client(timeout=timeout)

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


def prepare_text(text: str, max_chars: int) -> str:
    text = " ".join(text.split())
    # 空文書でも API に渡せるようプレースホルダを入れる(判定は実質ランダムになる)
    return text[:max_chars] if text else "(empty post)"


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, str, str]], out_path: Path,
        max_chars: 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, text, label in (progress(todo) if progress else todo):
            rec = {"idx": idx, "label": label}
            for attempt in range(retries):
                start = time.perf_counter()
                try:
                    rec.update(client.classify(prepare_text(text, max_chars)))
                    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 点です。

  • 長文は先頭 2,000 文字で切る:20 Newsgroups には非常に長い投稿が混ざっているためです。空白も正規化しています。
  • 結果を 1 件ずつ JSONL に追記する:途中で止めても、再実行すれば続きから処理できます。エラーで終わった件だけを再試行することもできます。
  • probabilities も保存する:後半のハイブリッド分析で使います。

実行用スクリプトでは、計測の前に 1 回ダミーのリクエストを送ってモデルをロードしておきます。初回のリクエストはモデルのロードで 5〜25 秒ほどかかるので、これを推論時間に含めないためです。

"""System One モデルで層化抽出したテストデータを Zero-Shot 分類する。"""

import argparse

from tqdm import tqdm

from systemone_textclf.data import LABEL_DESCRIPTIONS, RESULTS_DIR, load_dataset, sample_test_indices
from systemone_textclf.zeroshot import SystemOneClient, run


def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("--model", required=True)
    p.add_argument("--n", type=int, default=1000, help="層化抽出するテスト件数")
    p.add_argument("--limit", type=int, help="動作確認用に先頭から処理する件数")
    p.add_argument("--max-chars", type=int, default=2000)
    args = p.parse_args()

    ds = load_dataset()
    idx = sample_test_indices(ds.test.labels, args.n)
    if args.limit:
        idx = idx[: args.limit]
    items = [(i, ds.test.texts[i], ds.target_names[ds.test.labels[i]]) for i in idx]

    client = SystemOneClient(args.model, LABEL_DESCRIPTIONS)
    client.classify("warm up")  # モデルのロード時間を計測に含めないため

    out = RESULTS_DIR / f"zeroshot_{args.model}.jsonl"
    run(client, items, out, max_chars=args.max_chars,
        progress=lambda xs: tqdm(xs, desc=args.model))
    print(f"saved: {out}")


if __name__ == "__main__":
    main()

実行

まずは --limit 5 で動作確認をしてから、本番を流します。VRAM 8GB だと複数のモデルを同時に載せきれないため、1 モデルずつ順番に実行しました。

$ uv run scripts/run_zeroshot.py --model kev --limit 5   # 動作確認
$ uv run scripts/run_zeroshot.py --model kev
$ uv run scripts/run_zeroshot.py --model tev1
$ uv run scripts/run_zeroshot.py --model d1-omni

1,000 件の処理時間は次のとおりで、3 モデルともエラーはありませんでした。

モデル処理時間(1,000 件)1 件あたり
kev約 11 分669 ms
tev1約 7.5 分454 ms
d1-omni約 1 分 10 秒71 ms

結果:kev は Logistic Regression とほぼ互角

scripts/compare.py で集計した結果です(抽出した 1,000 件での比較)。

手法学習データAccuracyMacro-F11 件あたり推論時間
TF-IDF + LR11,314 件0.6890.6790.06 ms
kev(Zero-Shot)なし0.6840.675669 ms
tev1(Zero-Shot)なし0.6660.665454 ms
d1-omni(Zero-Shot)なし0.6160.61371 ms

kev は学習データなしで、チューニング済みの Logistic Regression と 0.5 ポイント差でした。前述のとおり 1,000 件での誤差は ±3 ポイントほどあるため、実質的に互角と言えます。20 クラスある分類を、ラベルの説明文だけでここまでこなせるのは注目すべき結果です。

一方で、推論時間は約 1 万倍違います。

Logistic Regression の推論時間は 7,532 件をまとめて predict した時間を件数で割ったもので、System One Models は HTTP リクエスト 1 件ずつの往復時間です。条件はそろっていませんが、桁が違うことは間違いありません。

クラスごとに見ると得意・不得意がはっきりしている

クラス別の F1 を見ると、全体の数字だけでは分からない傾向が見えてきます。

クラスTF-IDF + LRkevtev1d1-omni
rec.sport.hockey0.8870.9500.9180.833
rec.sport.baseball0.8040.9060.9140.838
rec.autos0.6030.8070.7920.736
sci.space0.7500.8160.8330.816
misc.forsale0.7330.8040.7870.725
talk.politics.guns0.5890.4000.3610.395
alt.atheism0.5450.3640.2680.438
talk.religion.misc0.4360.2240.3300.080

(20 クラスから、差が大きいものを抜粋しています)

  • スポーツ・自動車・宇宙・売買のように話題がはっきりしたクラスでは、Zero-Shot が Logistic Regression を上回ります。
  • 宗教系(alt.atheism / talk.religion.misc / soc.religion.christian)や政治系(talk.politics.*) のように、内容が重なり合うクラスでは大きく負けます。

「無神論の議論」と「その他の宗教の議論」の境界は、ラベルの説明文だけでは伝えきれません。実際の投稿から境界を学習できる Logistic Regression のほうが有利になる、というのは納得のいく結果です。

confidence は「目安」としてはよく効く

前回の記事で「confidence は正答率ではない」と書きました。では、正答率とまったく無関係かというとそうでもありません。confidence の区間ごとに正答率を集計してみました。

confidencekev 件数kev 正答率tev1 件数tev1 正答率d1-omni 件数d1-omni 正答率
0.0–0.21240.161130.154370.135
0.2–0.41460.397540.2041720.192
0.4–0.61680.5651320.3111530.425
0.6–0.81960.8111270.4491500.547
0.8–1.03660.9626740.8234880.883

kev は confidence と正答率がきれいに連動しています。confidence 0.8 以上の 366 件では正答率 96% です。「confidence が高いものだけ自動で判定し、残りは人が確認する」という運用に十分使えそうです。

一方で tev1 は、674 件(約 3 分の 2)で confidence 0.8 以上を出しながら、その正答率は 82% にとどまります。同じ confidence でもモデルによって意味が違う、という点には注意が必要です。

複数の System One Models を組み合わせたらどうなるか

ここまでの結果を踏まえると、次の疑問が出てきます。

  • kev が自信のない文書は、tev1 や d1-omni でも自信がないのか?
  • 複数のモデルを組み合わせれば、精度は上がるのか?

これを scripts/hybrid.py で検証しました。

kev が苦手な文書は、他のモデルも苦手

まずは各モデルの confidence の相関(Spearman)です。

kevtev1d1-omni
kev1.000.750.65
tev10.751.000.67
d1-omni0.650.671.00

かなり強い相関があります。kev の confidence の区間ごとに、他のモデルの正答率も見てみましょう。

kev の confidence件数kevtev1d1-omniどれか 1 つでも正解
0.0–0.21240.1610.1940.1530.347
0.2–0.41460.3970.3840.3560.589
0.4–0.61680.5650.4880.4940.714
0.6–0.81960.8110.7960.6730.857
0.8–1.03660.9620.9510.9020.964

kev が迷う文書では、tev1 も d1-omni も同じように間違えています。正誤の重なりを集計すると、3 モデルとも間違えた文書が 1,000 件中 230 件 ありました。3 モデルのうちどれか 1 つでも正解すれば正解とみなす理論上の上限(オラクル)でも、正答率は 77.0% にしかなりません。

さらに、tev1 は kev の confidence が 0.2〜0.4 の文書に対して平均 0.71 の confidence を出していました。それでいて正答率は 38% です。高い confidence を出しながら間違える傾向があり、そのままカスケードの判断材料にするのは危険です。

ハイブリッド戦略の比較

それでも、組み合わせ方によっては効果があるかもしれません。そこで、次の戦略を試しました。

  • 確率平均:各モデルの probabilities を平均して最大のクラスを選ぶ
  • 多数決:3 モデルの多数決(バラバラなら kev を採用)
  • confidence 最大:一番自信のあるモデルの判定を採用
  • 重み付き確率平均:モデルごとの重みをグリッドサーチで決める
  • カスケード:速いモデルの confidence がしきい値 t 未満のときだけ、遅いモデルに回す

重みやしきい値を同じ 1,000 件で選んで評価すると、結果が楽観的になりすぎます。そこで、5-fold で「選ぶデータ」と「評価するデータ」を分けるようにしました。

def cv_select(y, candidates, n_splits=5):
    """candidates: {名前: (予測 [n], コスト [n])}。fold ごとに訓練側の accuracy 最大の候補を選び、評価側の予測を集める。"""
    pred = np.empty_like(y)
    cost = np.empty(len(y))
    chosen = []
    for tr, te in StratifiedKFold(n_splits, shuffle=True, random_state=SEED).split(np.zeros(len(y)), y):
        best = max(candidates, key=lambda k: (candidates[k][0][tr] == y[tr]).mean())
        chosen.append(best)
        pred[te], cost[te] = candidates[best][0][te], candidates[best][1][te]
    return pred, cost, chosen

カスケードの候補は、しきい値 t を 0.05 刻みで振って作ります。コストには「回送されたときだけ後段の推論時間を足す」形で、実測の推論時間を使っています。

def cascade(first, second, avg):
    cands = {}
    for t in np.arange(0.0, 1.0001, 0.05):
        esc = o[first]["conf"] < t
        fallback = (o[first]["P"] + o[second]["P"]).argmax(1) if avg else pred[second]
        cands[round(float(t), 2)] = (np.where(esc, fallback, pred[first]),
                                     lat[first] + np.where(esc, lat[second], 0.0))
    return cands

結果は次のとおりです。

戦略AccuracyMacro-F1平均推論時間/件
kev 単体0.6840.674669 ms
確率平均(kev + tev1 + d1-omni)0.6780.6701194 ms
多数決0.6830.6761194 ms
confidence 最大0.6750.6681194 ms
重み付き確率平均(kev の重み ≒ 0.8)0.6880.6811194 ms
カスケード:d1-omni → kev(回送率 約 66%)0.6800.670494 ms
カスケード:tev1 → kev(回送率 約 32%)0.6830.677662 ms
(参考)オラクル0.770––
(参考)TF-IDF + LR0.6890.6790.06 ms

最も精度が高かった重み付き確率平均でも、kev 単体より 0.4 ポイント高いだけでした。しかも重みはほぼ kev に寄っており、実質的に kev 単体で判定しているのに近い結果です。均等な確率平均や confidence 最大のモデルを採用する方法では、むしろ kev 単体より下がりました。

前回の記事でも触れた「アーキテクチャが違っても、同じ入力で一様に失敗する」という注意点を、手元のデータで確認する形になりました。同じ系統のモデルを組み合わせても、間違いは打ち消し合わないということです。

それでもハイブリッドが役立つ場面

精度向上には効きませんでしたが、ハイブリッドが役に立つ場面は 2 つありました。

  1. 「判定がそろうか」を信頼度の目安にする
    3 モデルの判定が一致した 625 件では、正答率が 85.4% でした。一致しなかった 375 件では、kev の正答率が 40% まで落ちます。一致したものだけ自動処理する、という運用の判断材料に使えます。
  2. 速いモデルで振り分けて、推論時間を減らす
    「まず d1-omni で判定し、confidence が低い約 66% だけ kev に回す」カスケードを使うと、精度の低下は 0.4 ポイントで済み、平均推論時間は 669 ms から 494 ms と約 26% 減りました。大量の文書をさばく場合には効いてきます。

組み合わせる相手は Logistic Regression のほうが有望

最後に、Logistic Regression と各モデルの間違い方の重なりも調べました。

組み合わせ両方とも間違えた件数オラクル判定が一致した件数一致時の正答率
System One 3 モデル2300.7706250.854
LR × kev2020.7986480.887
LR × tev12030.7976280.889
LR × d1-omni2250.7756070.873

LR と kev の 2 つだけの組み合わせのほうが、System One 3 モデルの組み合わせより間違いの重なりが小さくなりました。オラクルの上限も、判定が一致したときの正答率も上回っています。

学習データから境界を学ぶ Logistic Regression と、ラベルの説明文から判断する Zero-Shot とでは、間違え方の重なりが小さいということです。ハイブリッドにするなら、System One Models 同士よりも、性質の違うモデルと組み合わせたほうが効果的だと言えそうです。

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

  • ラベルの説明文次第で結果は変わる
    今回試した criteria は 1 パターンだけです。苦手だった宗教系・政治系のクラスは、説明文の書き方を工夫すれば改善する余地があります。裏を返せば、Zero-Shot の精度は「説明文の出来」に左右されるため、本番で使う前に検証用データでの確認は欠かせません。
  • 1,000 件の比較には ±3 ポイントの誤差がある
    「kev と LR はほぼ互角」「重み付き平均で 0.4 ポイント改善」といった差は、いずれも統計的な誤差の範囲内です。小さな差だけを見て優劣を判断しないよう注意が必要です。
  • confidence の意味はモデルごとに違う
    kev の confidence 0.8 と tev1 の confidence 0.8 は、同じ信頼度ではありません。しきい値を使った運用をするなら、モデルごとに検証データで正答率を確認してからしきい値を決める必要があります。

おわりに

20 Newsgroups という古典的なベンチマークを使って、System One Models(kev / tev1 / d1-omni)による Zero-Shot 分類を、Optuna でチューニングした TF-IDF + Logistic Regression と比較しました。

一番の収穫は、学習データを一切使わない kev が、チューニング済みの Logistic Regression とほぼ互角の精度(Accuracy 0.684 vs 0.689)を出せること、そして kev の confidence が実際の正答率とよく連動していることを手元の数字で確かめられた点でした。ラベル付きデータを用意できない段階での立ち上げや、分類カテゴリが頻繁に変わる用途では、有力な選択肢になります。confidence が高いものだけ自動で処理し、低いものは人が確認するといった振り分けルールとも相性が良さそうです。

一方で、学習データが十分にあるなら、Logistic Regression は精度で並んだうえで推論速度が約 1 万倍速いという事実も見逃せません。また、System One Models を複数組み合わせても、苦手な文書では同じように間違えるため精度はほとんど伸びませんでした。「どれか 1 つの手法に任せる」あるいは「同じ系統のモデルを重ねる」のではなく、古典的な機械学習モデルと Zero-Shot モデルのように性質の異なる手法を組み合わせて互いの弱点を補う設計が、実務における現実的な落としどころになりそうです。

More Information