判定特化モデル「Jev」で1,000行の名簿の名寄せをやってみる

判定特化モデル「Jev」で1,000行の名簿の名寄せをやってみる

複数の業務システムから吸い上げた顧客テーブルを1つに統合する際、名寄せで手が止まってしまうことはよくあります。そこで今回は、TypeSafeの判定特化モデル「Jev」を使った名寄せの実装に挑戦してみました。出力形式の指示も不要で、確率と確信度だけが型付きで返ってくるシンプルさが、この問題を効率的に解きます。
2026.09.24

はじめに

データ事業本部のkobayashiです。

複数の業務システムから吸い上げた顧客テーブルを1つに統合しようとして、名寄せで手が止まる、ということはよくあるかと思います。人が見れば一瞬で「同じ人だ」とわかるのに、コードで書こうとすると正規化ルールが際限なく増えていきます。

そこで今回は、TypeSafeが公開している 「判定だけをする」モデル「Jev」 に名寄せの判定を任せてみました。Jevは文章を1文字も生成せず、答えのラベルと確率と確信度だけを型付きで返します。

本記事では、JevのPython SDKの使い方、それを使った名寄せの実装と結果、「判定特化」が何に効いたのかを順にまとめます。

Jevとは:文章を書かず、判定だけを返すモデル

JevはTypeSafeが「System One」と呼ぶ種類のモデルで、同社のフラッグシップにあたります。名前は『ファスト&スロー』の「システム1(速くて直感的な思考)」から取られています(System One)。

実際に名寄せで使ってみて感じたのは、できることが極端に狭い代わりに、返ってくるものが完全に決まっているモデルだということです。自然言語の指示は受け取りますが、返すのは質問の型で決めた答えと確率だけで、文章は1文字もありません。「この2行は同じ人か」のように答えの形が最初から決まっている仕事では、その狭さがそのまま扱いやすさになることがわかります。

LLMとの違い

LLMに判定をさせる場合、「same/different/reviewのどれかをJSONで答えて」とプロンプトで出力形式を指示し、生成された文章をパースし、形式が崩れていたらリトライする……という手間がかかります。そもそもLLMは人が読む文章を生成するためのモデルなので、コードが使う判定結果を出させるのは、生成モデルを無理やり判定器として使っている状態です。

Jevはここが根本的に違います。

項目 LLM Jev
出力 生成された文章 型付きの答え(選択肢・スコア・真偽の確率)
出力形式の指定 プロンプトで指示し、パースする 質問の型で決まる。パース不要
確からしさ 「自信度: 高」などと文章で言わせるしかない 選択肢ごとの確率確信度が数値で返る
理由の説明 できる できない(しない)
料金 入力+出力トークン 入力トークンのみ(出力は無料)

この違いは学習のさせ方から来ています。TypeSafeは自社の手法を RLCD(Reinforcement learning for calibrated decisions) と呼んでおり、チャットボットを生んだRLHF、推論モデルを生んだRLVRに続く3つ目の後処理として位置づけています(AI primer)。狙いは、文章ではなく判定とキャリブレーションされた確率を返させることです。

キャリブレーションされているというのは、たくさんの判定を集めたときに「確率0.8と答えたものは約80%の割合で当たる」という性質のことです(1件1件の正しさを保証するものではありません)。つまり確率をそのまま閾値に使ってコードで分岐するといったことができます。

3つの質問の型

Jevへの質問は、次の3つの型のどれかで表します。型が違っても1回のリクエストにまとめて投げられるので、後述のquickstartでは3種類を1回で聞いています。

目的 返ってくる値
Choice 選択肢から1つ選ぶ choice, probabilities, confidence
Score 順序付きの段階で採点する score, probabilities, confidence, legend
Noul その文は真か? noul(真である確率 0〜1)

料金はjev-1.13.0入力100万トークンあたり$0.042、出力トークンは無料です(Models)。文章を生成しないので出力で課金されることもなく、生成を待つ時間もありません。

Python SDKの使い方

では早速、JevのPython SDKtypesafe-sdkを試してみます。

環境

今回使用した環境は以下の通りです。

  • Python: 3.13.15
  • typesafe-sdk: 0.7.1
  • モデル: jev-1.13.0

インストールとAPIキー

SDKはPython 3.10以上が必要です。インストールし、TypeSafeのコンソールで発行したAPIキーを環境変数に設定します。

$ pip install typesafe-sdk
$ export TYPESAFE_API_KEY=...

SDKは以下の環境変数を読み込みます(Usage)。モデルを指定しなければjev-latest(執筆時点ではjev-1.13.0)が使われます。

環境変数 内容 デフォルト
TYPESAFE_API_KEY APIキー(必須) -
TYPESAFE_BASE_URL APIのURL https://api.typesafe.ai
TYPESAFE_DEFAULT_MODEL デフォルトのモデル jev-latest
TYPESAFE_LOG_LEVEL typesafe_sdkロガーのレベル 未設定

基本の呼び出し

呼び出しはclient.system_one(state=..., questions=...)の1メソッドだけです。stateに判定対象(文字列・JSONオブジェクト・文字列の配列)を、questionsに「名前→質問」の辞書を渡します。以下は、名簿の2行について3つの型の質問をまとめて投げる例です。stateinstructionscriteriaも、日本語でそのまま書けます。

quickstart.py
from typesafe_sdk import Choice, Noul, Score, TypeSafeClient

client = TypeSafeClient()

# 名簿の 2 行。空の列は入れない
pair = {
    "a": {"氏名": "田中 雄郎", "フリガナ": "タナカ ユウロウ", "メール": "yuro.tanaka50@abc.example.co.jp",
          "電話": "000-5309-5369", "住所": "東京都渋谷区道玄坂1-6-13", "会社": "株式会社ABC"},
    "b": {"氏名": "田中 雄郎", "フリガナ": "たなか ゆうろう", "メール": "YURO.TANAKA50@ABC.EXAMPLE.CO.JP",
          "電話": "000-5309-5369", "会社": "株式会社ABC"},
}

response = client.system_one(
    state=pair,
    questions={
        # 2 行の違いはどんな種類か(選択肢から 1 つ)
        "difference": Choice(
            instructions="`a` と `b` の違いはどんな種類か",
            criteria={
                "notation": "空白・かなとカナ・大文字と小文字など、書き方だけが違う",
                "missing": "片方にしかない項目があるが、両方にある項目は一致している",
                "conflict": "氏名・会社・連絡先など、値そのものが食い違う項目がある",
            },
        ),
        # 同じ人か(3 段階で採点)
        "same_person": Score(
            instructions="`a` と `b` は同じ人を表しているか",
            criteria=["別人", "判断できない", "同じ人"],
        ),
        # 同じ会社か(真偽の確率)
        "same_company": Noul(
            instructions="`a` と `b` の会社は同じ会社である",
        ),
    },
)

print(response.choices["difference"].choice)
print(response.scores["same_person"].score)
print(response.nouls["same_company"].noul)
print(response.model_dump_json(indent=2))

stateには辞書をそのまま渡せます。どの質問もinstructions(何を判定するか)とcriteria(選択肢や段階の説明)を自然言語で書くだけです。ここでは 出力形式の指示をどこにも書いていません 。答えの形は質問の型で決まるので、プロンプトで「JSONで答えて」と頼む必要がありません。

実行結果は以下の通りです。

$ python quickstart.py
missing
1.99
0.98
{
  "model": "jev-1.13.0",
  "usage": {
    "input_tokens": 696,
    "output_tokens": 71
  },
  "answers": {
    "difference": {
      "type": "choice",
      "choice": "missing",
      "confidence": 0.4,
      "probabilities": {
        "conflict": 0.01,
        "missing": 0.59,
        "notation": 0.4
      }
    },
    "same_person": {
      "type": "score",
      "score": 1.99,
      "confidence": 0.99,
      "legend": {
        "0": "別人",
        "1": "判断できない",
        "2": "同じ人"
      },
      "probabilities": {
        "0": 0.0,
        "1": 0.01,
        "2": 0.99
      }
    },
    "same_company": {
      "type": "noul",
      "noul": 0.98
    }
  }
}

同じ人かどうかは「同じ人」の確率が0.99・確信度0.99、同じ会社である確率は0.98と、はっきりした答えが返ってきました。

ここでsame_personscoreが1.99になっているのは、scoreが確率ではなく、段階の番号(0〜2)を確率で重み付けした平均(期待値) だからです。

score = 0 × 0.0(別人) + 1 × 0.01(判断できない) + 2 × 0.99(同じ人) = 1.99

「同じ人である確率」を知りたいときはprobabilitiesの段階2の値(0.99)を見ます。scoreは「どの段階寄りか」を1つの数字で表したもので、段階が多い質問で「1.5以上なら」のように閾値を掛けたいときに使います。確信度confidenceは、これらとは別に「答えにどれだけ迷いがないか」を表す値です。

違いの種類のほうは、missing(0.59)とnotation(0.40)で確率が割れ、確信度は0.40と低く出ています。実際この2行は、住所が片方にしかない(missing)うえに、フリガナのかなとカナ、メールの大文字と小文字も違う(notation)ので、どちらの選択肢にも当てはまります。選択肢が重なっていれば、Jevは無理に1つに決めず、迷いを確率と確信度でそのまま返すことがわかります。選択肢は重ならないように書くのが基本ですが、確信度が低い答えを見れば「質問の作りが悪い」ことも判断できます。

Choiceは選んだ選択肢に加えてすべての選択肢の確率を、Scoreは段階ごとの確率と、その期待値であるscore(段階の番号の平均)を返します。どちらにも確信度confidenceが付きます。文章は1文字も含まれていません。

レスポンスを型付きで受け取る

SDKのレスポンスはSystemOneResponseというpydanticモデルで、質問の型ごとに答えを取り出すプロパティが用意されています。

response.choices["difference"]      # ChoiceAnswer(choice, probabilities, confidence)
response.scores["same_person"]      # ScoreAnswer(score, probabilities, confidence, legend)
response.nouls["same_company"]      # NoulAnswer(noul)
response.model                      # 実際に答えたモデルのバージョン(例: "jev-1.13.0")
response.usage.input_tokens         # 入力トークン数(課金対象)
response.request_id                 # 問い合わせ用のリクエストID

さらにresponse_modelに自分で定義したモデルを渡すと(Usage)、質問の名前を属性として型付きでアクセスできます。エディタの補完や型チェックも効くので、判定結果をアプリケーションのコードに組み込むときに便利です。

from typesafe_sdk import Noul, NoulAnswer, SystemOneResponse, TypeSafeClient

class CompanyResponse(SystemOneResponse):
    same_company: NoulAnswer

with TypeSafeClient() as client:
    result = client.system_one(
        pair,
        {"same_company": Noul(instructions="`a` と `b` の会社は同じ会社である")},
        response_model=CompanyResponse,
    )
    if result.same_company.noul > 0.8:   # 属性として型付きで取り出せる
        ...

非同期クライアント・モデルの固定・リトライ

大量の判定を投げるときは非同期クライアントAsyncTypeSafeClientを使います。使い方は同期版と同じで、awaitを付けるだけです。

from typesafe_sdk import AsyncTypeSafeClient, RetryPolicy

async with AsyncTypeSafeClient(
    model="jev-1.13.0",                              # エイリアスではなくバージョンで固定
    retry=RetryPolicy(max_retries=3, timeout=10.0),  # リトライとタイムアウト
) as client:
    response = await client.system_one(state=..., questions=...)
  • モデルの固定: jev-latestはエイリアスなので、新しいモデルが出ると答えが変わる可能性があります。確信度の閾値を調整した後は、model="jev-1.13.0"のようにバージョンを固定しておくと安心です
  • リトライ: SDKはレート制限(429 Too Many Requests)などに対して、デフォルトでバックオフ付きのリトライを行います。RetryPolicyでクライアント単位・呼び出し単位に調整できます
  • エラー処理: APIのエラーはTypeSafeAPIError(とそのサブクラス)として送出され、statusrequest_idを持っています

SDKの使い方は以上です。stateと型付きの質問を渡し、型付きの答えを受け取る」、これだけです。では、これを使って名寄せをやってみます。

名寄せの仕組み

素朴に考えると、1,000行の名簿から同じ人を探すには全ての組を比べる必要があります。しかし1,000行の総当たりは 499,500組(1,000行から2行を選ぶ組み合わせで、1,000 × 999 ÷ 2)です。今回の実測値(1組あたり約630トークン)で計算すると、総当たりだと約3億1,500万トークン、約$13.2かかります。安いモデルとはいえ、10万行の名簿になれば組の数は約50億組(100,000 × 99,999 ÷ 2)なので現実的ではありません。

そこで今回は以下の2段階に分けました。

  1. 候補探し(コード): 「同じ人かもしれない組」をルールで大雑把に絞る
  2. 判定(Jev): 絞った候補の組だけをJevに「同じ人か?」と聞く

コードは「見落とさないように広めに拾う」、Jevは「拾った組を1つずつ見極める」という役割分担です。これで50万組が1,520組に減り、費用も約1/330になりました。

jev-tableを試してみる

ここからは、表記揺れと重複を含む架空の名簿meibo.csvを名寄せしていきます。列は氏名,フリガナ,メール,電話,住所,会社の6つで、1,000行のうち150行が「同じ人の別表記」です。同じ人の組(163組)は正解としてmeibo.answers.csvに行番号で入っているので、判定結果と突き合わせて精度を測れます。

名簿から10行を抜き出すと、以下のようになっています(説明のため、同じ人の行を隣に並べ替えています)。

氏名,フリガナ,メール,電話,住所,会社
齊藤秋菜,サイトウ アキナ,akina.saito35@example.co.jp,000-7766-8885,福岡県福岡市博多区博多駅前3丁目12-30,株式会社山田電機
斉藤 秋菜,サイトウ アキナ,akina.saito35@example.co.jp,000-7766-8885,福岡県福岡市博多区博多駅前3-12-30,株式会社山田電機
田中 大香,タナカ ダイカ,daika.tanaka38@example.co.jp,000-3572-4276,愛知県名古屋市中区栄3-13-17,株式会社クラスメソッド
田中 大香,タナカ ダイカ,daika.tanaka38@example.ne.jp,000-3572-4276,愛知県名古屋市中区栄3丁目13-17,(株)クラスメソッド
佐藤 大咲,サトウ ダイサキ,daisaki.sato38@example.ne.jp,000-8145-4291,,株式会社サンプル商事
佐藤 大咲,サトウ ダイサキ,DAISAKI.SATO38@EXAMPLE.CO.JP,000-8145-4291,,(株)サンプル商事
吉田 陽菜,ヨシダ ヨウナ,yona.yoshida54@example.co.jp,000-1486-6640,東京都千代田区大手町3-12-20,株式会社東京システム
吉田 陽菜,ヨシダ ヨウナ,yona.yoshida77@example.co.jp,000-3246-1339,北海道札幌市中央区北一条西3-6-8,株式会社東京システム
松本 太咲,マツモト タサキ,tasaki.matsumoto70@example.co.jp,000-2966-5958,大阪府大阪市北区梅田5-11-25,株式会社クラスメソッド
山田 優美,ヤマダ ユウミ,yumi.yamada39@example.co.jp,000-5908-7088,東京都渋谷区道玄坂2-15-20,株式会社ぴよぴよ
  • 1〜2行目:同じ人。旧字体の「齊」と氏名の空白、住所の「丁目」表記が違う
  • 3〜4行目:同じ人。氏名の全角空白、個人のメールアドレス、住所の「丁目」表記、「(株)」と「株式会社」が違う
  • 5〜6行目:同じ人。個人のメールと会社のメール(大文字)、「(株)」と「株式会社」が違う
  • 7〜8行目:別人。同姓同名で会社も同じだが、メール・電話・住所がすべて違う
  • 9〜10行目:重複のない行

名簿はすべて架空のデータです。メールは例示用に予約されたドメイン(example.co.jpexample.ne.jp)だけを使い、電話番号は実在しない000始まりにしています(日本の電話番号は先頭の0の次が0になりません)。

表記揺れは次の中から2〜4個を組み合わせて入れてあります。

  • 氏名の空白の有無・全角空白、旧字体(高→髙、崎→﨑、斉→齊)
  • フリガナのひらがな化、欠落
  • メールの大文字化、個人アドレス(example.ne.jp)への変更、欠落
  • 電話のハイフン除去、欠落
  • 住所の都道府県の省略と「丁目」表記、欠落
  • 会社名の「株式会社」の位置と「(株)」

7〜8行目のような「同姓同名の別人」を同じ人にまとめてしまわないことも、名寄せでは大事なポイントです。

1. 候補探し

まずはコードで候補を絞ります。やっていることは次の2つだけです。

  • メール・電話を正規化(全角半角・大文字小文字・空白・記号をならす)して、同じ値なら候補
  • 氏名の文字2-gramの一致率(Jaccard係数)が0.5以上なら候補。「山田太郎」「山田 太郎」「山田太朗」はここで拾える
dedupe.py
def norm(s: str) -> str:
    """全角半角・大文字小文字・空白・記号の違いをならす。"""
    s = unicodedata.normalize('NFKC', s or '').lower()
    return re.sub(r'[\s\W_]+', '', s)

def bigrams(s: str) -> set:
    return {s[i:i + 2] for i in range(len(s) - 1)} or {s}

def find_candidates(rows: list, name_col: str, exact_cols: list, min_sim: float) -> set:
    """同じ人かもしれない行の組 (i, j) を返す。i < j。"""
    pairs = set()

    # メール・電話など、値が同じなら候補
    for col in exact_cols:
        by_value = defaultdict(list)
        for i, r in enumerate(rows):
            v = norm(r.get(col, ''))
            if v:
                by_value[v].append(i)
        for idxs in by_value.values():
            pairs.update((a, b) for a in idxs for b in idxs if a < b)

    # 氏名の 2-gram がよく重なれば候補(「山田太郎」と「山田 太朗」など)
    grams = [bigrams(norm(r.get(name_col, ''))) for r in rows]
    index = defaultdict(list)
    for i, g in enumerate(grams):
        for x in g:
            index[x].append(i)
    for i, g in enumerate(grams):
        seen = set()
        for x in g:
            for j in index[x]:
                if j > i and j not in seen:
                    seen.add(j)
                    sim = len(g & grams[j]) / len(g | grams[j])
                    if sim >= min_sim:
                        pairs.add((i, j))
    return pairs

2-gramの転置インデックスを作っておき、同じ2-gramを持つ行同士だけ一致率を計算しているので、総当たりをせずに済みます。

ここで大事なのは、候補探しの段階では精度を求めないことです。「同姓の別人」も大量に拾いますが、それはJevが後で弾いてくれます。逆にここで取りこぼすと後段では救えないので、ゆるめに広く拾う方向に倒しています。

--dry-runを付けると、Jevを呼ばずに候補の組数と費用の見積もりだけを出せます。

$ python3 dedupe.py meibo.csv --dry-run
1000 候補 1520 組(見積もり: 957,600 トークン、約 $0.040)

499,500組が1,520組まで減りました。正解の163組はすべてこの1,520組に含まれています。

2. Jevで判定する

続いて、候補の組ごとにJevに「同じ人か?」を聞きます。先ほどのSDKの使い方そのままで、質問はScoreを1つだけです。

dedupe.py
from typesafe_sdk import AsyncTypeSafeClient, Score

# Jev に渡す 3 段階。段階の説明そのものが判定基準になる
LEVELS = [
    '別人。',
    '同じ人かもしれないが、重要な点が食い違う(名が違う、会社も電話も違う、判断するには情報が少なすぎる)。',
    '同じ人。違いは表記・空白・カナと漢字・新旧の住所・社名の変更・別のメールや電話だけ。',
]
STATUS = {0: 'different', 1: 'review', 2: 'same'}
QUESTION = Score(
    instructions='名簿の 2 行 `a` と `b` は同じ人を表しているか。',
    criteria=LEVELS,
)

Scoreは「段階(criteria)」を順序付きで定義し、状態がどの段階に当てはまるかを採点させる質問です。今回は以下の3段階にしました。

段階 意味 status
0 別人 different
1 同じ人かもしれないが、重要な点が食い違う(名が違う、会社も電話も違う、情報が少なすぎる) review
2 同じ人。違いは表記・空白・カナと漢字・新旧住所・社名変更・別のメールや電話だけ same

ポイントは、段階の説明文そのものが判定基準になることです。「表記・空白・カナと漢字の違いは同じ人とみなす」「名が違うなら要確認」といった、これまでならif文で延々と書いていたルールを、自然言語で1行ずつ書くだけで済みます。自分たちの名簿の事情に合わせたい場合(例えば「部署が違っても同じ人」「旧姓の可能性がある」など)も、この説明を書き換えるだけです。

また、「同じ/別人」の2択ではなく 「要確認」という中間の段階を用意している のもポイントです。判断に迷う組を無理に白黒つけさせず、人に回すための逃げ道を作っておきます。

なお、この「2件を1つのstateに入れ、Scoreの3段階で『別物・要確認・同じ』を判定する」組み立ては、TypeSafe公式のcookbook「Knowledge graph entity alignment」を参考にしています。cookbookではビールのカタログ同士を突き合わせていますが、同じ考え方が日本語の名簿にもそのまま使えます。

実際に呼び出す部分は以下です。2行をabとしてそのままstateに渡しています。

dedupe.py
def record(r: dict) -> dict:
    return {k: v for k, v in r.items() if v}

async def judge(rows: list, pairs: list, concurrency: int) -> tuple:
    """候補の組ごとに Jev に聞く。(level, probabilities, confidence) のリストと入力トークン数を返す。"""
    sem = asyncio.Semaphore(concurrency)
    done, tokens = 0, 0

    async def one(client, i, j):
        nonlocal done, tokens
        async with sem:
            resp = await client.system_one(
                state={'a': record(rows[i]), 'b': record(rows[j])},
                questions={'same': QUESTION},
            )
        tokens += resp.usage.input_tokens or 0
        done += 1
        if sys.stderr.isatty() or done % 200 == 0:
            print(f'\r  判定中… {done}/{len(pairs)} リクエスト', end='', file=sys.stderr, flush=True)
        a = resp.scores['same']
        level = max(a.probabilities, key=a.probabilities.get)   # いちばん確率の高い段階
        return level, a.probabilities, a.confidence

    async with AsyncTypeSafeClient() as client:
        results = await asyncio.gather(*(one(client, i, j) for i, j in pairs))
    print(file=sys.stderr)
    return results, tokens

Jevに関係するコードはclient.system_one()の呼び出しと、resp.scores['same']から確率と確信度を取り出す数行だけです。プロンプトの組み立ても、出力のパースも、形式崩れのリトライもありません。stateには辞書をそのまま渡せるので、2行をJSONのまま渡しています。空の列はrecord()で落としておき、「欠けている情報」を「空文字という値」と誤解させないようにしています。

判定は、各段階の確率のうち最も高い段階をその組の結果にしています。sameでも確信度(confidence)が--min-confidence(既定0.6)未満ならreviewに回します。確率と確信度が数値で返ってくるので、「どこから人が見るか」をコード側の閾値1つで調整できます。

3. グループにまとめる

最後にsameと判定された組をつないで、同じ人の行に同じgroup_idを振ります。A=B、B=Cなら A・B・C は同じグループ、というよくあるUnion-Findを行えば完成です。

実行する

--dry-runを外して実行すると、以下の2ファイルが出力されます。

  • meibo.groups.csv: 元の名簿にgroup_id列を足したもの。重複が無い行は空
  • meibo.pairs.csv: 候補の組ごとの判定結果。status、「同じ」の確率p_same、「要確認」の確率p_review、確信度confidencep_sameの高い順に並べたもの
$ python3 dedupe.py meibo.csv
1000 候補 1520 組(見積もり: 957,600 トークン、約 $0.040)
  判定中… 200/1520 リクエスト  判定中… 400/1520 リクエスト  判定中… 600/1520 リクエスト  判定中… 800/1520 リクエスト  判定中… 1000/1520 リクエスト  判定中… 1200/1520 リクエスト  判定中… 1400/1520 リクエスト
リクエスト 1520 回、7.4 秒、入力 963,949 トークン(約 $0.040)
重複グループ 144 件(302 行)、要確認 644
出力: meibo.groups.csv, meibo.pairs.csv

1,520回のリクエストが7.4秒で終わり、144人ぶん・302行の重複が見つかりました。

meibo.groups.csvには、元の名簿にgroup_id列が足されています。同じgroup_idの行が同じ人です。

氏名,フリガナ,メール,電話,住所,会社,group_id
鈴木 陽輔,,yosuke.suzuki59@example.co.jp,000-9859-2318,千代田区大手町4丁目5-27,株式会社山田電機,G0001
鈴木 陽輔,スズキ ヨウスケ,yosuke.suzuki59@example.co.jp,000-9859-2318,東京都千代田区大手町4-5-27,株式会社山田電機,G0001
伊藤 拓太,いとう たくた,takuta.ito19@example.co.jp,000-2392-4020,福岡県福岡市博多区博多駅前5丁目3-22,ぴよぴよ株式会社,G0002
伊藤 拓太,イトウ タクタ,takuta.ito19@example.co.jp,000-2392-4020,福岡県福岡市博多区博多駅前5-3-22,株式会社ぴよぴよ,G0002
鈴木 裕一,,,000-9410-2128,福岡県福岡市博多区博多駅前4-18-7,株式会社山田電機,G0003
鈴木 裕一,スズキ ヒロイチ,hiroichi.suzuki24@example.co.jp,000-9410-2128,福岡県福岡市博多区博多駅前4-18-7,株式会社山田電機,G0003

フリガナやメールの欠落、都道府県の省略と「丁目」表記、ひらがなのフリガナ、「株式会社」の位置違いといった揺れがあっても、同じ人としてまとめられていることが確認できます。

meibo.pairs.csvは候補の組ごとの判定で、p_sameの高い順に並んでいます。

row_a,row_b,a,b,status,p_same,p_review,confidence
4,86,鈴木 陽輔,鈴木 陽輔,same,1.0,0.0,0.99
9,835,伊藤 拓太,伊藤 拓太,same,1.0,0.0,0.99
12,851,鈴木 裕一,鈴木 裕一,same,1.0,0.0,0.99
17,109,鈴木 優香,鈴木 優香,same,1.0,0.0,0.99
19,144,佐藤 智郎,佐藤 智郎,same,1.0,0.0,1.0

2行目の伊藤 拓太の組(上のG0002)は、フリガナのひらがなとカタカナ、住所の「丁目」表記、「株式会社」の位置が違いますが、p_sameが1.0・確信度0.99で同じ人と判定されています。

一方、reviewに回った組の先頭はこのような組です。

row_a,row_b,a,b,status,p_same,p_review,confidence
494,529,吉田 陽菜,吉田 陽菜,review,0.74,0.25,0.59

名簿の例で7〜8行目に挙げた組です。元の行を見ると以下の通りです。

吉田 陽菜,ヨシダ ヨウナ,yona.yoshida54@example.co.jp,000-1486-6640,東京都千代田区大手町3-12-20,株式会社東京システム
吉田 陽菜,ヨシダ ヨウナ,yona.yoshida77@example.co.jp,000-3246-1339,北海道札幌市中央区北一条西3-6-8,株式会社東京システム

同姓同名で会社も同じですが、メール・電話番号・住所がすべて違います。p_sameは0.74で「同じ」が一番高いものの、確信度が0.59と--min-confidence(0.6)をわずかに下回ったのでreviewに回っています。「人が見て決めるべき組」です。

結果

1,000行・候補1,520組をjev-1.13.0で判定した結果は以下の通りです。meibo.pairs.csvと正解のmeibo.answers.csvを突き合わせるスクリプトevaluate.pyで集計しました。

$ python3 evaluate.py meibo
正解の組 163、候補に含まれた組 163
  same        171 組(うち正解 163)
  review      644 組(うち正解 0)
  different   705 組(うち正解 0)
different p_same の最大: 0.07
  誤って same: 270 494(吉田 陽菜 / 吉田 陽菜、p_same 0.88)
  誤って same: 74 923(木村 翔菜 / 木村 翔菜、p_same 0.85)
  誤って same: 349 621(井上 拓人 / 井上 拓人、p_same 0.85)
  誤って same: 707 852(鈴木 智樹 / 鈴木 智樹、p_same 0.82)
  誤って same: 486 758(田中 直二 / 田中 直二、p_same 0.81)
  誤って same: 652 780(木村 達恵 / 木村 達恵、p_same 0.8)
  誤って same: 140 593(佐藤 千郎 / 佐藤 千郎、p_same 0.75)
  誤って same: 580 764(伊藤 麻樹 / 伊藤 麻樹、p_same 0.75)
  • 所要時間: 7.4秒(並列64)
  • トークン数: 1組あたり約630トークン、合計約96万トークン
  • 費用: 約$0.040
status 組数 内訳
same 171 正解163、誤り8
review 644
different 705 正解は0組(p_sameの最大は0.07)

取りこぼしは0

正解の163組はすべてsame と判定されました。空白の種類違い、旧字体、ひらがなのフリガナ、個人のメールへの変更、「(株)」表記、情報の欠落といった揺れを2〜4個重ねた組でも、1つも取りこぼしていません。

またdifferentと判定された705組の中に正解は1組もなく、p_sameの最大値も0.07でした。名寄せでは「同じ人を別人として残してしまう」ミスが一番後から見つけにくいので、ここが0なのは良い結果だと思います。

誤判定の8組

誤ってsameにした8組は、いずれも同姓同名・同じ会社で、メール・電話番号・住所が違う組でした。

木村 翔菜,キムラ ショウナ,shona.kimura42@example.co.jp,000-2689-1085,愛知県名古屋市中区栄2-7-23,株式会社ほげふが
木村 翔菜,キムラ ショウナ,shona.kimura15@example.co.jp,000-2430-8801,愛知県名古屋市中区栄3-19-17,株式会社ほげふが

段階2の説明には「違いが新旧住所や別のメールや電話だけなら同じ人」と書いているので、これらの組は段階2の記述にそのまま当てはまります。Jevは理由を返さないので判断の中身は確かめられませんが、判定基準の書き方がそのまま結果に出ているのだと思います。判定を変えたいなら基準の書き方を変える必要があります。例えば「メール・電話・住所がすべて違うなら要確認」と段階1に書き足すのが1つの手です。

ただし、今回の名簿は会社が8社しかなく、メールも名.姓+数字@会社ドメインと機械的に作っているため、こうした「ほぼ同じだけど別人」が偶然できやすくなっています。人が見ても迷う組かと思います。

reviewが多い理由

reviewが644組と多めなのも同じ理由で、「同姓同名だが会社やメールが違う」組が大量にあるためです。Jevは段階の説明どおり「同じ人かもしれないが、重要な点が食い違う」に振り分けており、判定としては妥当かと思います。

実際の名簿ではここまで同姓同名が集中することは少ないと思いますが、reviewが多すぎる場合は次の2つで調整できます。

  • --min-confidenceを下げて、sameに入る組を増やす
  • LEVELSの説明を、自分たちの名簿の事情に合わせて書き換える

meibo.pairs.csvp_sameの高い順に並んでいるので、reviewの組を上から見ていけば、人の確認作業も効率よく進められます。

並列数で速くする

最初は並列数8で動かしていて、1,000行に47秒かかっていました。もう少し速くならないのかと思い、並列数を変えて測ってみました。

1組につき1回のリクエストを投げるので、1,520組なら1,520回です。1回あたりの応答は速いので、同時に投げる数(--concurrency)を増やせば、そのぶん全体が速くなるはずです。

$ python3 dedupe.py meibo.csv --concurrency 8
リクエスト 1520 回、47.1 秒、入力 963,949 トークン(約 $0.040)
$ python3 dedupe.py meibo.csv --concurrency 32
リクエスト 1520 回、12.2 秒、入力 963,949 トークン(約 $0.040)
$ python3 dedupe.py meibo.csv --concurrency 64
リクエスト 1520 回、7.4 秒、入力 963,949 トークン(約 $0.040)
$ python3 dedupe.py meibo.csv --concurrency 128
リクエスト 1520 回、5.3 秒、入力 963,949 トークン(約 $0.040)
並列数 所要時間 same(うち正解) review different
8 47.1秒 170(163) 648 702
32 12.2秒 172(163) 650 698
64 7.4秒 171(163) 644 705
128 5.3秒 170(163) 641 709

並列数を上げるほどきれいに速くなり、128では5.3秒になりました。どの並列数でも正解163組はすべてsameです。誤ってsameにした組は7〜9組で、reviewdifferentの境目でも十数組が実行ごとに入れ替わっていますが、どれも確率や確信度が境目付近の組なので、並列数に関係なく実行ごとに起きる揺れです。費用も変わりません。

ただし、この表は各並列数につき1回ずつ測っただけの値です。ネットワークやAPI側の混み具合にも左右されるので、傾向として見てください。

なお、ドキュメント上のjev-1.13.0のレート制限は「毎分1,200リクエスト」ですが、同じページに「制限は需要に応じて動的に調整中」と注記されています。今回は毎分1万リクエストを超えるペースでも問題なく最後まで処理できましたが、いつでもこの速度が出るとは限りません。制限に当たって429が返ってきても、SDKがバックオフしてリトライしてくれます。本記事のスクリプトでは、余裕を見て既定値を64にしています。

「判定特化」が名寄せで効いたところ

実際に1,500組を判定させてみて、Jevが「判定しかしない」モデルであることが、名寄せでは次のように効いていると感じました。

1. 判定ロジックの「外側」のコードがほぼゼロ

LLMで同じことをやると、「出力形式を指示するプロンプト」「生成された文章からラベルを取り出すパーサー」「sameでもdifferentでもない文字列が返ってきたときのリトライ」が必要になります。1,500回も呼べば、何回かは形式が崩れることを覚悟しないといけません。

Jevでは答えがScoreの段階(0・1・2)のどれかとして型付きで返ってくるので、そもそも崩れようがありません。今回のjudge()関数も、system_one()を呼んでprobabilitiesconfidenceを取り出すだけで終わっています。

2. 確率が「使える」数値として返ってくる

名寄せで一番ほしいのは「同じ人か」というラベルそのものよりも、「どれくらい自信を持って同じと言えるか」 です。それが決まれば「確実なものは自動でまとめる、迷うものは人が見る」という仕分けができます。

LLMに「確信度を0〜1で答えて」と頼んでも、それは生成された文章の中の数字にすぎません。Jevのprobabilitiesconfidenceは、確率がキャリブレーションされるように学習されたモデルが返す値です。今回も、differentと判定した705組のp_sameは最大でも0.07で、その中に正解は1組もありませんでした。低いと答えた確率は実際に低く出ていることがわかります。ただしこれは架空の名簿1セットでの結果なので、キャリブレーションの良し悪しをきちんと確かめるなら、確率帯ごとの的中率を自分のデータで測る必要があります。

だからこそ--min-confidenceという閾値1つでsamereviewの境界を動かす、という設計がそのまま成り立ちます。

3. 判定基準を自然言語で書ける

ルールベースの名寄せでは、「全角空白と半角空白」「髙と高」「(株)と株式会社」……と、揺れのパターンを1つずつコードで吸収していく必要があります。JevではScoreの段階の説明に「違いが表記・空白・カナと漢字・新旧住所・社名変更・別のメールや電話だけなら同じ人」と書くだけで済みました。今回の揺れはどれも個別のルールを書いていませんが、正解163組をすべてsameにできています。

4. 速い・安い

出力トークンが無料で、課金は入力トークンだけです。1,520組で約96万トークン、約$0.040・7.4秒(並列128なら5.3秒)でした。文章を生成しないので1回の応答が速く、並列数を上げればそのまま全体が速くなります。これくらいのコストなら、名簿を更新するたびに気軽に回し直せます。

Jevに向かないこと

逆に、Jevは理由を文章で説明してくれません 。「なぜ同じ人と判断したか」を監査用に残したい、1件ずつ深く推論させたい、といった用途にはLLMのほうが向いています。

まずJevで大量の組を速く安くさばき、reviewに回った組だけを人やLLMが詳しく見ます。今回はreviewが644組(候補1,520組の4割強)と多めに出ましたが、p_sameの高い順に並んでいるので上から順に確認していけます。「判定はJev、説明と推論はLLMと人」 という役割分担が現実的だと思います。

注意点

  • 名簿の中身はTypeSafeのAPIに送信されます。 実在の個人情報を扱う場合は、契約や社内規程を必ず確認してください
  • 候補探しは文字の一致が頼りなので、「山田」と「ヤマダ」のように表記が丸ごと違う行は候補に上がりません。フリガナがそろっている名簿なら--name フリガナで拾えます
  • --min-confidenceを自分の名簿に合わせたら、SDKのmodel="jev-1.13.0"でモデルのバージョンを固定しておくとよいです。jev-latestのエイリアス先が変わって判定が動くのを防げます(同じバージョンでも、実行ごとの揺れ自体は残ります)

まとめ

TypeSafeの判定特化モデルJevとPython SDKtypesafe-sdkで、1,000行の名簿の名寄せを試してみました。

SDKはclient.system_one()stateと型付きの質問(ChoiceScoreNoul)を渡すだけで、答えと確率と確信度が型付きで返ってきます。出力形式の指示もパースも要りません。

名寄せはコードで候補を広めに絞り、Jevで1組ずつScoreの3段階に採点させる2段構えで、7.4秒・約4セント・取りこぼし0という結果になりました。判定基準を自然言語で書くだけで済み、仕分けも確信度の閾値1つで調整できます。

LLMに無理やり判定させている処理に心当たりがあれば、ぜひ試してみてください。

最後まで読んで頂いてありがとうございました。

この記事をシェアする

関連記事