Serverless Operations, inc

>_cd /blog/id_4qi3k80pahqx

title

Jevを使ったクレジットカード不正利用検知の可能性を探る

Jev(読み方は「ジェブ」)は、米国の TypeSafe AI が 2026年9月15日に公開した AI モデルです。XなどのSNSを中心とした話題の盛り上がりにより急な需要が増加し現時点では、申し込みが一時停止されています。

https://typesafe.ai/

ChatGPT や Claude のような LLM(大規模言語モデル)との一番の違いは、文章を一切書かないことです。Jev が返すのは、こちらが事前に決めた選択肢の中から選んだ答えと、その確率だけです。

LLMモデルは進化を続けかなり複雑な様々なワークロードに対応し、自動化はプログラムとの連携など用途も広がり続けていますが、基本は対話、つまりチャットモデルです。つまり人間にとって自然な対話が行えるような能力備えています。

AI AgentプログラムからLLMを呼び出す場合、時としてこのリッチなレスポンスは邪魔になります。なぜならLLMからのレスポンスを受け取ったデータはプログラムによってパースされ必要なパラメータのみを取り出し次の処理に続けることが期待されているためです。

であれば逆転の発想として、シンプルに与えられたプロンプトが、与えられた選択肢のどれに該当するか?を確率のみで戻すことでより軽量により安価にLLMの判断能力を使おうというのがJevの基本コンセプトです。

実際やってみるとわかりますが、従来数秒かかっていたレスポンスが数百msまで短縮されリアルタイムトランザクションの判別にも利用可能なレベルまで処理が短縮されます。

そのJevをクレジットカード決済の不正利用検知に使ってみようというのがこのブログの趣旨です。

返せる答えは3種類だけ

Jevが戻せる答えはシンプルで以下の3つだけです。

型

内容

例

Noul

真か偽かを、真である確率(0〜1)で返す

この文章に個人情報が含まれるか? → 0.97

Choice

複数の候補から1つを選ぶ

問い合わせの種類は? → 「請求」

Score

説明付きの段階評価で返す

危険度は? → 低・中・高のうち「高」寄り

使い方は単純です。判断の材料になる状況(state)と、上の型で書いた質問(questions)を API に送るだけです。返ってくる値は型が決まっているので、LLM のように出力の文章を解析する処理がいりません。結果をそのままプログラムの if 文に使えます。そのため Jev は「賢い if 文」としてコードの中に埋め込むことができます。

LLM との比較

観点

一般的な LLM

Jev

出力

自由な文章(解析や検証が必要)

型の決まった値と確率

応答時間

数秒〜数十秒以上

70〜500ミリ秒程度

入力料金(100万トークンあたり)

約 0.2〜10 ドル

約 0.042 ドル

出力料金

入力の数倍

無料

得意なこと・苦手なこと

能力を特化させ高速性と安価なコストを実現していますので何でもできるわけではありません。

  • 得意:分類、振り分け、段階評価、真偽判定など、答えの形を事前に決められる判断。
  • 苦手:文章の作成、要約、説明、コードの生成。画像・音声・動画の入力にも対応していません。

なぜ不正利用検知を試すのか

クレジットカードの承認判断には、次のような条件があります。

  • 1件ごとに一瞬で判断しなければならない
  • 取引の件数が膨大なので、1件あたりのコストを抑えたい
  • 欲しい答えは「不正か正常か」「承認・保留・拒否のどれか」と、形が決まっている

これは「速い・安い・型の決まった答えを返す」という Jev の特徴とよく合います。本記事では、まず単純なサンプルで Jev の基本的な使い方を確認し、次に不正利用検知のサンプルを作って、どこまで使えるかを探っていきます。

さっそくやってみる:まず簡単なサンプル

いきなり不正検知に入る前に、Jev の基本的な使い方を簡単な例で確かめます。お題は「文章に個人情報が含まれているかを判定する」です。答えは「含む/含まない」と「個人情報の種類」なので、Jev の得意な、答えの形を事前に決められる判断にぴったりです。

まず環境構築です。

sudo apt update && sudo apt install -y python3-venv python3-pip

# 作業フォルダと仮想環境を作る
mkdir -p ~/jev-test && cd ~/jev-test
python3 -m venv .venv
source .venv/bin/activate

# Jev の SDK をインストール
pip install typesafe-sdk

APIキーはこちらから発行できます。

https://console.typesafe.ai/keys

ただし記事執筆時点での2026/10/3ではあまりの急激な需要の高まりにより新規申し込みの一時停止措置が取られています。定期的にサイトを訪れて確認してみてください。

キーは環境変数 TYPESAFE_API_KEY に設定します。毎回入力しなくて済むように、専用のファイルに保存して ~/.bashrc から読み込むようにしました。

# 画面に表示されず、コマンド履歴にも残らない方法でキーを入力
read -s -p "TypeSafe API key: " KEY; echo
echo "export TYPESAFE_API_KEY=\"$KEY\"" > ~/.typesafe_env
chmod 600 ~/.typesafe_env && unset KEY

# ターミナル起動時に読み込む
echo '[ -f ~/.typesafe_env ] && source ~/.typesafe_env' >> ~/.bashrc
source ~/.bashrc

jev_pii_test.pyを作成します。

from typesafe_sdk import Choice, Noul, TypeSafeClient

# (テスト文, 期待値: 個人情報を含むか)
SAMPLES = [
    ("明日の会議は10時から第2会議室で行います。", False),
    ("担当の山田太郎(090-1234-5678)までご連絡ください。", True),
    ("東京都渋谷区神南1-2-3に住む佐藤花子さん宛に送付済みです。", True),
    ("今期の売上は前年比120%で着地しました。", False),
    ("ログイン用メールは taro.yamada@example.com です。", True),
    ("田中さんが昨日ランチでカレーを食べていました。", None),  # グレーゾーン
]

QUESTIONS = {
    "has_pii": Noul(
        instructions="この文章に個人を特定できる情報(個人情報)が含まれているか?",
        criteria={
            "true": "氏名・電話番号・住所・メールアドレスなど、特定の個人を識別できる情報が含まれる",
            "false": "特定の個人を識別できる情報は含まれない",
        },
    ),
    "pii_type": Choice(
        instructions="最も主要な個人情報の種類は?",
        criteria={
            "none": "個人情報なし",
            "name": "氏名のみ",
            "contact": "電話番号やメールアドレスなどの連絡先",
            "address": "住所",
        },
    ),
}

THRESHOLD = 0.5


def main() -> None:
    ok = total = 0
    with TypeSafeClient(model="jev-latest") as client:
        for text, expected in SAMPLES:
            res = client.system_one(state=text, questions=QUESTIONS)
            p = res.nouls["has_pii"].noul
            kind = res.choices["pii_type"].choice
            pred = p >= THRESHOLD
            if expected is None:
                mark = "  -"
            else:
                total += 1
                ok += pred == expected
                mark = " OK" if pred == expected else " NG"
            print(f"[{mark}] P(PII)={p:.2f} 種類={kind:<8} {text}")
    print(f"\n正解率: {ok}/{total}")


if __name__ == "__main__":
    main()

python jev_pii_test.pyで実行を行います。

コードのポイント

① クライアントは with 文で作るだけ

with TypeSafeClient(model="jev-latest") as client:

API キーは、環境変数 TYPESAFE_API_KEY に設定してあれば自動で読み込まれます。コードにキーを書く必要はありません。model="jev-latest" を指定すると、その時点で最新の Jev が使われます。今回使った SDK(typesafe-sdk)のバージョンは 0.7.2 です。

② state には「判断材料」を渡す

res = client.system_one(state=text, questions=QUESTIONS)

state は、Jev が判断の根拠にする材料です。今回は判定したい文章をそのまま渡しました。

ここには、人が読んで判断できるように書いたテキストを渡すのがコツです。

③ questions に「何を聞くか」と「答えの形」を書く

1回の API 呼び出しで、型の違う質問をまとめて聞けます。

            p = res.nouls["has_pii"].noul
            kind = res.choices["pii_type"].choice
  • has_pii は Noul 型です。答えが「真」である確率が 0〜1 で返ってきます。
  • pii_type は Choice 型です。4つの候補から1つが選ばれます。
    "pii_type": Choice(
        instructions="最も主要な個人情報の種類は?",
        criteria={
            "none": "個人情報なし",
            "name": "氏名のみ",
            "contact": "電話番号やメールアドレスなどの連絡先",
            "address": "住所",
        },

instructions が質問文、criteria がそれぞれの答えの意味です。Jev はこの説明文だけを手がかりに判断するので、ここが実質的な「プロンプト」になります。「氏名・電話番号・住所・メールアドレスなど」のように具体例を並べると、判断の基準がぶれにくくなります。

Choice 型で注意したいのは、どの選択肢にも当てはまらない場合でも、必ずどれか1つを選んでしまうことです。そのため、"none": "個人情報なし" のような「該当なし」の選択肢を必ず用意しておきます。

④ 結果は型付きの値として取り出せる

p    = res.nouls["has_pii"].noul       # float(0〜1)
kind = res.choices["pii_type"].choice  # str("none" / "name" / ...)

結果は、質問に付けた名前(has_pii など)をキーにして取り出します。返ってくるのは決まった型の値なので、そのまま if 文に使えます。LLM のように、返ってきた文章から答えを抜き出したり、決めた形式になっているかを確かめたりする処理がいりません。

それぞれの答えには、確信度(confidence)と、選択肢ごとの確率(probabilities)も付いてきます。ただし、確信度は「この答えが正解である確率」そのものではありません。ご注意ください。

⑤ しきい値は使う側が決める

THRESHOLD = 0.5
pred = p >= THRESHOLD

Jev は「個人情報あり/なし」を決めません。返すのは確率だけで、どこで線を引くかは使う側に任されています。今回は 0.5 にしましたが、見逃しを減らしたいなら値を下げ、誤検知を減らしたいなら値を上げます。

「確率は Jev、最終判断はコード」という役割分担が、Jev を使うときの基本の形です。

実行してみると以下となります。

[ OK] P(PII)=0.04 種類=none     明日の会議は10時から第2会議室で行います。
[ OK] P(PII)=0.99 種類=contact  担当の山田太郎(090-1234-5678)までご連絡ください。
[ OK] P(PII)=0.98 種類=address  東京都渋谷区神南1-2-3に住む佐藤花子さん宛に送付済みです。
[ OK] P(PII)=0.03 種類=none     今期の売上は前年比120%で着地しました。
[ OK] P(PII)=0.98 種類=contact  ログイン用メールは taro.yamada@example.com です。
[  -] P(PII)=0.80 種類=name     田中さんが昨日ランチでカレーを食べていました。

正解率: 5/5

クレジットカード不正検知のサンプル

上記の仕組みを、本題のクレジットカード不正利用検知に当てはめます。

1件の取引について、次の3つを1回の API 呼び出しでまとめて聞きます。

質問

型

返ってくる値

is_fraud:この取引は不正利用か?

Noul

不正である確率(0〜1)

risk_level:不正リスクの高さは?

Score

0(低)〜2(高)の値

pattern:当てはまる不正パターンは?

Choice

次の5つから1つ

pattern の選択肢は次の5つです。

  • none:該当なし
  • card_testing:テスト決済(盗んだカードが使えるかを少額で試す)
  • account_takeover:なりすまし
  • impossible_travel:移動不可能(物理的にたどり着けない場所での決済)
  • unusual_amount:異常な金額

Score 型の値は、各段階の確率で重み付けした平均です。そのため 1.37 のような中間の値も返ってきます。

最終的な判断は、第2部と同じくコード側で行います。

FRAUD_TH = 0.7    # これ以上 → 拒否(DECLINE)
REVIEW_TH = 0.3   # これ以上 → 目視確認(REVIEW)

架空の取引を9件用意しました。

ID

内容

正解

T001

新宿のコンビニで昼食 3,480円

正常

T002

深夜、ナイジェリアのIP・新しい端末から家電を 298,000円、配送先は転送業者

不正

T003

1分おきに120円前後の決済が連続(1時間に14回)

不正

T004

京都のホテル予約 45,000円(毎年この時期に予約あり)

正常

T005

東京で決済した3時間後に、パリのブランド店で 86,000円

不正

T006

機種変更直後に家電を 65,000円

グレー

T007

成田空港で決済した16時間後に、パリのレストランで 72,000円(渡航届あり)

正常

T008

深夜、国内IPから寄付サイトへ 500円の決済が1時間に9回

不正

T009

引っ越し直後に家具を 150,000円

正常

注目してほしいのは T005 と T007 です。どちらも「パリでの高額決済」ですが、T005 は不正、T007 は本人の旅行です。この2件を見分けられるかが、今回の一つのポイントです。

カード番号は扱っていません。実運用でも、生のカード番号を外部の API に送るとカード業界のセキュリティ基準(PCI DSS)に抵触するので、必ずマスクした値を使ってください。

jev_fraud_test.py を作成します。

"""Jev(TypeSafe AI)でクレジット決済の不正検知を試すサンプル

実行:
    python jev_fraud_test.py                     # 内蔵サンプルで実行
    python jev_fraud_test.py transactions.csv    # 自前のCSVで実行
    python jev_fraud_test.py --template          # CSVのひな型 (transactions_template.csv) を出力

出力:
    画面に判定結果、fraud_results.csv に1件ずつの詳細、最後にしきい値ごとの検知漏れ/誤検知表

注意: カード番号は下4桁などマスク済みの値だけを渡してください(生のPANは送らない)。
"""
import csv
import sys
from datetime import datetime

from typesafe_sdk import Choice, Noul, Score, TypeSafeClient, TypeSafeError

FRAUD_TH = 0.7    # これ以上 → 拒否(DECLINE)
REVIEW_TH = 0.3   # これ以上 → 目視確認(REVIEW)
RESULT_CSV = "fraud_results.csv"

FIELDS = [
    "id", "amount", "currency", "merchant", "mcc", "time", "channel",
    "card_country", "ip_country", "home_area", "avg_amount", "tx_last_1h",
    "new_device", "shipping_match", "prev_location", "prev_hours_ago",
    "distance_km", "travel_notice", "note", "expected",
]

# expected: True=不正, False=正常, None=グレー(正解率の計算から除外)
SAMPLES = [
    dict(id="T001", amount=3480, currency="JPY", merchant="コンビニ(東京都新宿区)", mcc="5411",
         time="2026-09-28 12:15", channel="店頭IC", card_country="JP", ip_country="-",
         home_area="東京都", avg_amount=5000, tx_last_1h=1, new_device="no", shipping_match="-",
         prev_location="東京都新宿区", prev_hours_ago=20, distance_km=0, travel_notice="no",
         note="", expected=False),
    dict(id="T002", amount=298000, currency="JPY", merchant="家電ECサイト", mcc="5732",
         time="2026-09-28 03:42", channel="EC", card_country="JP", ip_country="NG",
         home_area="大阪府", avg_amount=8000, tx_last_1h=6, new_device="yes",
         shipping_match="no(転送業者の住所)", prev_location="-", prev_hours_ago="", distance_km="",
         travel_notice="no", note="直前に少額決済の失敗が5回", expected=True),
    dict(id="T003", amount=120, currency="JPY", merchant="海外デジタルコンテンツ", mcc="5815",
         time="2026-09-28 04:01", channel="EC", card_country="JP", ip_country="US",
         home_area="福岡県", avg_amount=6000, tx_last_1h=14, new_device="yes", shipping_match="-",
         prev_location="-", prev_hours_ago="", distance_km="", travel_notice="no",
         note="", expected=True),
    dict(id="T004", amount=45000, currency="JPY", merchant="ホテル(京都市)", mcc="7011",
         time="2026-09-27 15:30", channel="EC", card_country="JP", ip_country="JP",
         home_area="神奈川県", avg_amount=12000, tx_last_1h=1, new_device="no", shipping_match="-",
         prev_location="神奈川県横浜市", prev_hours_ago=48, distance_km=370, travel_notice="no",
         note="毎年この時期に旅行予約あり", expected=False),
    dict(id="T005", amount=86000, currency="JPY", merchant="ブランド品店(パリ)", mcc="5944",
         time="2026-09-28 18:20", channel="店頭IC", card_country="JP", ip_country="-",
         home_area="東京都", avg_amount=15000, tx_last_1h=1, new_device="no", shipping_match="-",
         prev_location="東京都港区(店頭IC)", prev_hours_ago=3, distance_km=9700, travel_notice="no",
         note="", expected=True),
    dict(id="T006", amount=65000, currency="JPY", merchant="家電ECサイト", mcc="5732",
         time="2026-09-28 21:10", channel="EC", card_country="JP", ip_country="JP",
         home_area="愛知県", avg_amount=9000, tx_last_1h=1, new_device="yes", shipping_match="yes",
         prev_location="愛知県名古屋市", prev_hours_ago=5, distance_km=5, travel_notice="no",
         note="スマホ機種変更直後", expected=None),
    dict(id="T007", amount=72000, currency="JPY", merchant="レストラン(パリ)", mcc="5812",
         time="2026-09-28 20:05", channel="店頭IC", card_country="JP", ip_country="-",
         home_area="東京都", avg_amount=15000, tx_last_1h=1, new_device="no", shipping_match="-",
         prev_location="成田空港(店頭IC)", prev_hours_ago=16, distance_km=9700, travel_notice="yes",
         note="", expected=False),
    dict(id="T008", amount=500, currency="JPY", merchant="寄付サイト", mcc="8398",
         time="2026-09-28 02:30", channel="EC", card_country="JP", ip_country="JP",
         home_area="北海道", avg_amount=7000, tx_last_1h=9, new_device="yes", shipping_match="-",
         prev_location="-", prev_hours_ago="", distance_km="", travel_notice="no",
         note="", expected=True),
    dict(id="T009", amount=150000, currency="JPY", merchant="家具ECサイト", mcc="5712",
         time="2026-09-27 11:00", channel="EC", card_country="JP", ip_country="JP",
         home_area="埼玉県", avg_amount=10000, tx_last_1h=1, new_device="no", shipping_match="yes",
         prev_location="埼玉県さいたま市", prev_hours_ago=30, distance_km=0, travel_notice="no",
         note="引っ越し直後", expected=False),
]

QUESTIONS = {
    "is_fraud": Noul(
        instructions="この取引は不正利用(本人以外による利用)か?",
        criteria={
            "true": "カード情報の盗用・なりすまし・テスト決済など、本人以外による不正な取引である",
            "false": "本人による正常な取引である(普段と違っても本人の行動として説明がつく)",
        },
    ),
    "risk_level": Score(
        instructions="この取引の不正リスクの高さは?",
        criteria=[
            "低:普段の利用傾向と一致し、不審な点がない",
            "中:普段と異なる点があるが、本人の利用として説明がつく",
            "高:複数の不審なシグナルが重なっており、不正の可能性が高い",
        ],
    ),
    "pattern": Choice(
        instructions="最も当てはまる不正パターンは?",
        criteria={
            "none": "不正パターンに該当しない",
            "card_testing": "短時間に少額決済が連続するなど、盗んだカードが使えるか試すテスト決済",
            "account_takeover": "新しい端末・国外IP・転送先配送など、なりすましによる購入",
            "impossible_travel": "前回決済からの経過時間では物理的に移動できない場所で決済されている",
            "unusual_amount": "他に不審な点はないが、普段の利用額から大きく外れた金額",
        },
    ),
}


def _num(v):
    try:
        return float(v)
    except (TypeError, ValueError):
        return None


def to_state(tx: dict) -> str:
    """取引データを Jev に渡すテキストに整形(計算できる特徴量はここで計算)"""
    amount, avg = _num(tx["amount"]), _num(tx["avg_amount"])
    ratio = f"平均の {amount / avg:.1f} 倍" if amount and avg else "不明"

    hours, dist = _num(tx.get("prev_hours_ago")), _num(tx.get("distance_km"))
    if hours and dist is not None:
        speed = dist / hours
        feasible = "移動可能" if speed <= 900 else "移動不可能(旅客機の速度 約900km/h を超える)"
        travel = (f"前回決済: {tx['prev_location']}、{hours:g}時間前、距離 約{dist:g}km\n"
                  f"必要な移動速度: 約{speed:,.0f}km/h → {feasible}")
    else:
        travel = "前回決済: 情報なし"

    try:
        hour = datetime.strptime(tx["time"], "%Y-%m-%d %H:%M").hour
        time_note = "(深夜帯)" if hour < 6 else ""
    except ValueError:
        time_note = ""

    lines = [
        f"金額: {tx['amount']} {tx['currency']}(この会員の平均利用額 {tx['avg_amount']}、{ratio})",
        f"加盟店: {tx['merchant']}(MCC {tx['mcc']})",
        f"日時: {tx['time']}{time_note}",
        f"チャネル: {tx['channel']}",
        f"カード発行国: {tx['card_country']} / 接続元IPの国: {tx['ip_country']}",
        f"会員の居住地: {tx['home_area']}",
        travel,
        f"海外渡航の事前登録: {tx.get('travel_notice', '不明')}",
        f"直近1時間の決済回数: {tx['tx_last_1h']}",
        f"新しい端末からの利用: {tx['new_device']}",
        f"配送先が登録住所と一致: {tx['shipping_match']}",
    ]
    if tx.get("note"):
        lines.append(f"補足: {tx['note']}")
    return "\n".join(lines)


def decide(p: float) -> str:
    if p >= FRAUD_TH:
        return "DECLINE"
    if p >= REVIEW_TH:
        return "REVIEW"
    return "APPROVE"


def load_csv(path: str) -> list[dict]:
    rows = []
    with open(path, encoding="utf-8-sig") as f:
        for r in csv.DictReader(f):
            exp = (r.get("expected") or "").strip().lower()
            r["expected"] = {"true": True, "1": True, "false": False, "0": False}.get(exp)
            rows.append(r)
    return rows


def write_template() -> None:
    with open("transactions_template.csv", "w", encoding="utf-8-sig", newline="") as f:
        w = csv.DictWriter(f, fieldnames=FIELDS)
        w.writeheader()
        for s in SAMPLES[:3]:
            w.writerow({**s, "expected": "" if s["expected"] is None else str(s["expected"]).lower()})
    print("transactions_template.csv を出力しました")


def threshold_table(results: list[dict]) -> None:
    labeled = [r for r in results if r["expected"] is not None]
    if not labeled:
        return
    print("\nしきい値ごとの精度(このしきい値以上を『検知』とした場合)")
    print(" しきい値  検知漏れ(FN)  誤検知(FP)  正解率")
    for th in (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9):
        fn = sum(1 for r in labeled if r["expected"] and r["p_fraud"] < th)
        fp = sum(1 for r in labeled if not r["expected"] and r["p_fraud"] >= th)
        acc = (len(labeled) - fn - fp) / len(labeled)
        mark = " ← 現在の REVIEW" if th == REVIEW_TH else (" ← 現在の DECLINE" if th == FRAUD_TH else "")
        print(f"   {th:.1f}      {fn:>4}         {fp:>4}      {acc:5.0%}{mark}")


def main() -> None:
    if "--template" in sys.argv:
        write_template()
        return
    txs = load_csv(sys.argv[1]) if len(sys.argv) > 1 else SAMPLES
    results = []

    with TypeSafeClient(model="jev-latest") as client:
        for tx in txs:
            try:
                res = client.system_one(state=to_state(tx), questions=QUESTIONS)
            except TypeSafeError as e:
                print(f"[ERR] {tx['id']} APIエラーのためスキップ: {e}")
                continue
            p = res.nouls["is_fraud"].noul
            risk = res.scores["risk_level"].score
            pattern = res.choices["pattern"].choice if p >= REVIEW_TH else "-"
            action = decide(p)
            exp = tx.get("expected")

            if exp is None:
                mark = "  -"
            else:
                mark = " OK" if (p >= REVIEW_TH) == exp else " NG"
            print(f"[{mark}] {tx['id']} {action:<7} P(不正)={p:.2f} リスク={risk:.2f}/2 "
                  f"パターン={pattern:<17} {tx['merchant']} {tx['amount']}{tx['currency']}")
            results.append(dict(id=tx["id"], action=action, p_fraud=round(p, 4),
                                risk=round(risk, 3), pattern=pattern, expected=exp,
                                merchant=tx["merchant"], amount=tx["amount"]))

    if not results:
        return
    with open(RESULT_CSV, "w", encoding="utf-8-sig", newline="") as f:
        w = csv.DictWriter(f, fieldnames=list(results[0].keys()))
        w.writeheader()
        w.writerows(results)

    labeled = [r for r in results if r["expected"] is not None]
    if labeled:
        ok = sum(1 for r in labeled if (r["p_fraud"] >= REVIEW_TH) == r["expected"])
        print(f"\n正解率: {ok}/{len(labeled)}(REVIEW 以上を検知とみなす)")
    threshold_table(results)
    print(f"\n詳細は {RESULT_CSV} に保存しました")


if __name__ == "__main__":
    main()

python jev_fraud_test.py で実行します。

[ OK] T001 APPROVE P(不正)=0.07 リスク=0.10/2 パターン=-                 コンビニ(東京都新宿区) 3480JPY
[ OK] T002 DECLINE P(不正)=0.93 リスク=2.00/2 パターン=account_takeover  家電ECサイト 298000JPY
[ OK] T003 DECLINE P(不正)=0.88 リスク=2.00/2 パターン=card_testing      海外デジタルコンテンツ 120JPY
[ OK] T004 APPROVE P(不正)=0.19 リスク=1.12/2 パターン=-                 ホテル(京都市) 45000JPY
[ OK] T005 DECLINE P(不正)=0.86 リスク=1.99/2 パターン=impossible_travel ブランド品店(パリ) 86000JPY
[  -] T006 REVIEW  P(不正)=0.40 リスク=1.60/2 パターン=unusual_amount    家電ECサイト 65000JPY
[ OK] T007 APPROVE P(不正)=0.27 リスク=1.49/2 パターン=-                 レストラン(パリ) 72000JPY
[ OK] T008 DECLINE P(不正)=0.80 リスク=1.99/2 パターン=card_testing      寄付サイト 500JPY
[ OK] T009 APPROVE P(不正)=0.14 リスク=1.05/2 パターン=-                 家具ECサイト 150000JPY

正解率: 8/8(REVIEW 以上を検知とみなす)

しきい値ごとの精度(このしきい値以上を『検知』とした場合)
 しきい値  検知漏れ(FN)  誤検知(FP)  正解率
   0.1         0            3        62%
   0.2         0            1        88%
   0.3         0            0       100% ← 現在の REVIEW
   0.4         0            0       100%
   0.5         0            0       100%
   0.6         0            0       100%
   0.7         0            0       100% ← 現在の DECLINE
   0.8         0            0       100%
   0.9         3            0        62%

コードのポイント

① 全体の流れ

1件の取引は、次の順番で処理されます。

取引データ(dict / CSV の1行)
  → to_state()        判断材料のテキストに整形(計算できる値はここで計算)
  → client.system_one()  Jev に3つの質問をまとめて投げる
  → decide()          不正の確率から 承認 / 目視確認 / 拒否 を決める
  → 画面表示と fraud_results.csv に保存

第2部との違いは、Jev に渡す前に to_state() という整形の処理を挟んでいる点です。この処理が、今回のコードでいちばん大事な部分です。

② 取引データは項目ごとに持つ

dict(id="T005", amount=86000, currency="JPY", merchant="ブランド品店(パリ)", ...
     prev_location="東京都港区(店頭IC)", prev_hours_ago=3, distance_km=9700,
     travel_notice="no", note="", expected=True),

取引は、金額・加盟店・接続元IPの国・前回決済の場所と経過時間・渡航届の有無などを、それぞれ独立した項目として持たせています。expected は正解のラベルで、True が不正、False が正常、None が判断の分かれるグレーです。グレーの取引は正解率の計算から外しています。

同じ項目名の CSV を渡せば、自分で用意したデータでも試せます(python jev_fraud_test.py transactions.csv)。--template を付けて実行すると、ひな型の CSV が出力されます。

③ to_state():計算で出せることはコードで済ませてから渡す

speed = dist / hours
feasible = "移動可能" if speed <= 900 else "移動不可能(旅客機の速度 約900km/h を超える)"
travel = (f"前回決済: {tx['prev_location']}、{hours:g}時間前、距離 約{dist:g}km\n"
          f"必要な移動速度: 約{speed:,.0f}km/h → {feasible}")

ここで、前回決済からの移動速度を計算し、「移動不可能」かどうかまで判定してから Jev に渡しています。同じように、金額が平均の何倍か(「平均の 5.7 倍」)や、深夜帯かどうかも、ここで計算して文章にしています。

つまり、「9,700km を3時間で移動するのは不可能」と判断しているのは Jev ではなく、Python の固定ルールです。Jev はその結果を受け取り、金額・端末・渡航届などと合わせて総合的に判断しています。計算はコード、総合判断は Jev という役割分担がポイントです。今後Jevモデル自体が進化するにつれ内部で様々な判断が可能になるかもしれませんが、この辺りは試しながら明確に外部コードで判断するもの、LLMに任せるものと使い分けるのが当面の間はよさそうです。

実際に Jev に渡しているテキストは、次のようになります(T005 の場合)。

金額: 86000 JPY(この会員の平均利用額 15000、平均の 5.7 倍)
加盟店: ブランド品店(パリ)(MCC 5944)
日時: 2026-09-28 18:20
チャネル: 店頭IC
カード発行国: JP / 接続元IPの国: -
会員の居住地: 東京都
前回決済: 東京都港区(店頭IC)、3時間前、距離 約9700km
必要な移動速度: 約3,233km/h → 移動不可能(旅客機の速度 約900km/h を超える)
海外渡航の事前登録: no
直近1時間の決済回数: 1
新しい端末からの利用: no
配送先が登録住所と一致: -

④ QUESTIONS:criteria で判断の境界を言葉にする

"false": "本人による正常な取引である(普段と違っても本人の行動として説明がつく)",

is_fraud の「false」側には、「普段と違っても本人の行動として説明がつく」と書き添えました。これがないと、旅行や引っ越しのように「普段と違うだけの本人の買い物」まで不正寄りに判定されやすくなります。

"unusual_amount": "他に不審な点はないが、普段の利用額から大きく外れた金額",
"impossible_travel": "前回決済からの経過時間では物理的に移動できない場所で決済されている",

pattern(Choice 型)の選択肢でも、意味が重なりそうなものは境界をはっきり書きます。「異常な金額」には「他に不審な点はないが」を付け、ほかのパターンとの使い分けを明確にしました。「移動不可能」の説明は、to_state() で出力する「前回決済」「移動」という言葉とそろえてあります。

"risk_level": Score(
    criteria=[
        "低:普段の利用傾向と一致し、不審な点がない",
        "中:普段と異なる点があるが、本人の利用として説明がつく",
        "高:複数の不審なシグナルが重なっており、不正の可能性が高い",
    ],
),

Score 型は、criteria にリストで段階を並べます。返ってくる値は、リストの先頭を 0 とした段階番号の、確率による重み付き平均です。3段階なら 0(低)〜2(高)の範囲で、1.49 のような中間の値も返ってきます。

⑤ decide():最終判断は2つのしきい値で3段階に分ける

def decide(p: float) -> str:
    if p >= FRAUD_TH:      # 0.7 以上
        return "DECLINE"
    if p >= REVIEW_TH:     # 0.3 以上
        return "REVIEW"
    return "APPROVE"

第2部では1つのしきい値で「あり/なし」の2択にしましたが、今回は2つのしきい値で3段階に分けています。確実に怪しい取引は自動で拒否し、判断が分かれる取引は人が目で確認します。実際の不正検知の運用に近い形です。

pattern = res.choices["pattern"].choice if p >= REVIEW_TH else "-"

第2部で触れたとおり、Choice 型は該当しなくても必ずどれかを選びます。そのため、不正の確率が低い取引では、パターンを表示しないようにしました。

⑥ 評価:しきい値ごとの精度表で「どこに線を引くか」を決める

for th in (0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9):
    fn = sum(1 for r in labeled if r["expected"] and r["p_fraud"] < th)
    fp = sum(1 for r in labeled if not r["expected"] and r["p_fraud"] >= th)

正解ラベルの付いた取引について、しきい値を 0.1〜0.9 まで動かしたときの検知漏れ(FN)と誤検知(FP)を数えています。今回の結果では、正常な取引の確率はいちばん高くても 0.27(T007)、不正な取引はいちばん低くても 0.80(T008)でした。この間の 0.3〜0.8 であれば、どこに線を引いても 100% になります。

実データでは、このすき間はもっと狭くなるはずです。この表を見ながら、見逃しと誤検知のどちらを優先するかを決めていくことになります。

ルールベースの不正検知が抱える4つの限界

「深夜の海外 IP からの高額決済は保留」「1時間に5回以上の決済は拒否」のような if-then 型のルールは、わかりやすく、すぐに導入できます。不正検知の基本として今も広く使われています。一方で、次のような限界がはっきりしてきました。

1. 新しい手口への対応が後手に回る

ルールは、過去に見つかった手口をもとに人が書くものです。初めて見る手口は素通りしてしまいます。Adyen の「2026 Fraud Report」によると、不正を働く側は何千通りもの組み合わせ(名義、決済手段など)を同時に試し、通る条件をリアルタイムで探っているといいます。固定の境目は、試行錯誤によって簡単に見抜かれてしまいます。

2. 誤検知で正常な顧客を失う

固定のルールは、正常な取引も巻き込みます。同じレポートでは、固定的な制御が 正常な顧客の最大10%をブロックしている こと、50%の事業者が誤って拒否する取引が増えた と答えていることが紹介されています。不正利用の発生率は 0.03% 程度なので、ほとんどの取引は正常です。不正を1件止めるために正常な取引を何件も断っていては、売上も顧客の信頼も失います。

3. 目視確認とルールの保守が重くなる

ルールを足していくと、例外や組み合わせが増えて管理しきれなくなります。保留された取引を人が確認する手間も膨らみます。同じレポートでは、58%の事業者が目視確認のコストが増えた と答えています。

4. 「文脈」を判断できない

今回のサンプルでいえば、「パリでの高額決済」という条件だけでは、T005(不正)と T007(本人の旅行)を区別できません。前回の決済からの移動速度、渡航届の有無、普段の利用額との比較など、複数の手がかりを組み合わせて初めて「本人の行動として説明がつくか」を判断できます。こうした組み合わせをすべてルールとして書き出すのは現実的ではありません。このため個別判断はルールベースで行いつつ最終的な総合判断はLLMという組み合わせが効力を発揮する可能性があります。

Written by
編集部

亀田 治伸

Kameda Harunobu

  • Facebook->
  • X->
  • GitHub->

Share

Facebook->X->
Back
to list
<-