skip to content
barorin&
Table of Contents

はじめに

仕訳データの分析で、最後まで機械化できずに残るのが摘要欄です。

  • 交際費の中身が、接待なのか社内の懇親なのかを見る
  • 計上された勘定科目が、摘要の内容と合っているかを見る
  • 「一式」「その他」で済まされていて、内容が追えない仕訳を拾う
  • 個人的な支出が紛れていないかを見る

金額と日付は、pandasで機械的に絞り込めます。承認限度額の直下に張り付いた支出や、休日に計上された仕訳は条件式で抽出できます。ところが摘要は条件式になりません。

キーワード検索で代用すると、すぐ限界が来ます。「ゴルフ」で引っかけても「GOLF」「コンペ」「ラウンド費用」は抜けます。表記ゆれを足していくと条件が膨らみ、誰も保守できないコードが残ります。そして、キーワードに当たったかどうかは分かっても、どれくらい確からしいのかが分かりません。

この記事では、Jev入門で確認した型付きの判断を、仕訳データに当てはめます。テキストの意味の判断だけをJevに渡し、集計としきい値の判断はpandas側に残します。

何をJevに任せ、何を任せないか

先に線引きを決めます。ここが曖昧なまま組むと、後から検証できない仕組みになります。

作業 担当 理由
金額の大小・合計・差額 pandas Jevは計算しない。数を数えられない
日付の前後・期末判定・経過日数 pandas Jevは日付を文字列として読む
件数の集計・重複の検出 pandas 同上
摘要が何を意味しているか Jev 条件式で書けない
記載が具体的かどうか Jev 同上
しきい値の設定 人(コード) 業務上の重要性の判断
最終的な結論 人 会計・監査上の判断

前提とするデータ

会計システムから出した仕訳の明細を想定します。

import pandas as pd
df = pd.read_csv("journal.csv")
print(df.head())
date voucher_no account amount description dept created_by
0 2026-03-31 J-1001 交際費 132000 ㈱大和田商事 接待 銀座 営業部 山田
1 2026-03-31 J-1002 交際費 98000 ゴルフコンペ参加費 取引先同伴 営業部 山田
2 2026-03-28 J-1003 会議費 4800 部内打合せ 弁当代 6名 経理部 佐藤
3 2026-03-28 J-1004 旅費交通費 38600 大阪出張 新幹線往復 営業部 鈴木
4 2026-03-29 J-1005 支払手数料 500000 コンサルティング料 3月分 経営企画 田中

借方・貸方の展開や月次集計はここでは扱いません。摘要の判定の部分だけを見ていきます。

摘要から3つの判断を同時に取る

Jevは、同じ状態に対する複数の質問を1回のリクエストで並列に評価します。往復を増やさずに済むので、必要な質問は最初から全部まとめて投げます。

ここでは3つ聞きます。

  • 費用区分はどれか(Choice)
  • 摘要の記載がどれくらい具体的か(Score)
  • 個人的な支出が混ざっている疑いを説明する必要があるか(Noul)
import pandas as pd
from typesafe_sdk import Choice, Noul, Score, TypeSafeClient
QUESTIONS = {
"expense_type": Choice(
instructions="この支出の費用区分はどれか。摘要と勘定科目だけから判断する",
criteria={
"meeting": "社内または取引先との打合せにかかる飲食・会場費",
"entertainment": "取引先の接待・贈答・供応。ゴルフや会食を含む",
"travel": "出張・移動にかかる交通費と宿泊費",
"supplies": "文具・備品などの消耗品",
"outsourcing": "外部への業務委託、コンサルティング",
"other": "上記のいずれにも当てはまらない、または判断できない",
},
),
"description_detail": Score(
instructions="摘要が、第三者が取引内容を特定できる程度に具体的か",
criteria=[
"「一式」「その他」など、内容がまったく特定できない",
"費目は分かるが、相手先や目的が書かれていない",
"相手先・目的・数量のいずれかが具体的に書かれている",
],
),
"private_use_doubt": Noul(
instructions="摘要の文言だけから見て、個人的な支出が混ざっている疑いを説明する必要がある",
),
}

criteria の書き方で結果が変わります。押さえておく点は3つです。

  • **選択肢には必ず other を入れる。**入れないと、どれにも当てはまらない摘要が無理やりどれかに寄ります
  • レベルの説明には数字を書かない。「3名以上」のような条件はコード側で持ちます
  • instructions は文字どおりに読まれます。「判断する」と書けばそのとおりに、「推測する」と書けばそのとおりに振る舞います

1行ずつ投げる関数を書きます。

def ask(client: TypeSafeClient, row: pd.Series) -> dict:
state = {
"勘定科目": row["account"],
"摘要": row["description"],
"計上部門": row["dept"],
}
res = client.system_one(state=state, questions=QUESTIONS)
a = res.answers
return {
"expense_type": a["expense_type"].choice,
"type_conf": a["expense_type"].confidence,
"detail_score": a["description_detail"].score,
"private_doubt": a["private_use_doubt"].noul,
"input_tokens": res.usage.input_tokens,
}
df = pd.read_csv("journal.csv")
with TypeSafeClient() as client:
judged = pd.DataFrame([ask(client, row) for _, row in df.iterrows()])
out = pd.concat([df, judged], axis=1)

state を辞書で渡しているのがポイントです。文字列を連結して渡すより、項目に名前が付いているほうが判断の材料として扱われやすくなります。ただし関係のない項目まで詰め込むと、無関係な記述が判断を引っ張ります。判断に必要な列だけを入れます。

結果はこうなります。

voucher_no account amount description expense_type type_conf detail_score private_doubt
J-1001 交際費 132000 ㈱大和田商事 接待 銀座 entertainment 0.952 1.81 0.04
J-1002 交際費 98000 ゴルフコンペ参加費 取引先同伴 entertainment 0.952 1.81 0.87
J-1003 会議費 4800 部内打合せ 弁当代 6名 meeting 0.952 1.81 0.04
J-1005 支払手数料 500000 コンサルティング料 3月分 outsourcing 0.952 1.81 0.04
J-1007 交際費 180000 懇親会 一式 entertainment 0.952 0.28 0.87
J-1008 雑費 50000 お車代 other 0.016 1.81 0.87
J-1010 業務委託費 990000 業務委託 3月分 一式 outsourcing 0.952 0.28 0.87

読み方を整理します。

  • detail_score が0に近いものは、摘要から取引内容が追えない仕訳です(J-1007、J-1010)
  • private_doubt が高いものは、説明を求める必要がある仕訳です(J-1002、J-1008)
  • type_conf が低いものは、Jevが費用区分を決めきれなかった仕訳です(J-1008「お車代」)

type_conf が低いのは失敗ではありません。判断が割れていることが分かるのが、この型の利点です。キーワード検索なら「当たらなかった」としか出ません。

confidence で人に回すものを決める

confidence は答えの中身ではなく、答えが1か所に集まっているかを表します。これをそのまま振り分けに使います。

def route(row: pd.Series) -> str:
if row["type_conf"] < 0.5:
return "人が判断"
if row["type_conf"] < 0.9:
return "採用(要確認フラグ)"
return "自動採用"
out["routing"] = out.apply(route, axis=1)
print(out["routing"].value_counts())

しきい値は、間違えたときに何が起きるかで決めます。

用途 しきい値の目安
分析用の仮の区分付け 低くてよい(0.5程度)
レビュー対象の絞り込み 中程度(0.7〜0.8)
帳簿や申告の数値に反映させる そもそも自動採用しない

最後の行が実務上いちばん大事です。**Jevの答えを、そのまま帳簿の値にしてはいけません。**区分の付け替えを提案するところまでで止めて、反映は人が承認する形にします。

機械的な検出と組み合わせてトリアージする

ここが本題です。pandasで出せる客観的な事実と、Jevのテキスト判断を別の列として持ち、組み合わせて絞り込みます。

def triage(out: pd.DataFrame) -> pd.DataFrame:
out = out.copy()
# 数値と日付の判定はコード側で持つ
out["date"] = pd.to_datetime(out["date"])
out["is_month_end"] = out["date"].dt.is_month_end
out["over_limit"] = out["amount"] >= 100_000
# Jev の判断はしきい値でフラグに落とす
out["needs_review"] = out["private_doubt"] >= 0.7
out["vague_description"] = out["detail_score"] < 1.0
out["type_mismatch"] = out["type_conf"] < 0.5
reasons = []
for _, r in out.iterrows():
rs = []
if r["needs_review"]:
rs.append("私的流用の疑いあり")
if r["vague_description"]:
rs.append("摘要が不十分")
if r["type_mismatch"]:
rs.append("費用区分を特定できず")
if r["over_limit"] and r["is_month_end"]:
rs.append("期末日・高額")
reasons.append(" / ".join(rs))
out["reason"] = reasons
return out.loc[out["reason"] != "", ["voucher_no", "account", "amount", "description", "reason"]]
print(triage(out).to_string(index=False))
voucher_no account amount description reason
J-1001 交際費 132000 ㈱大和田商事 接待 銀座 期末日・高額
J-1002 交際費 98000 ゴルフコンペ参加費 取引先同伴 私的流用の疑いあり
J-1007 交際費 180000 懇親会 一式 私的流用の疑いあり / 摘要が不十分 / 期末日・高額
J-1008 雑費 50000 お車代 私的流用の疑いあり / 費用区分を特定できず
J-1010 業務委託費 990000 業務委託 3月分 一式 私的流用の疑いあり / 摘要が不十分 / 期末日・高額

reason を文字列で持たせているのには理由があります。**なぜ拾われたのかが行に書いてあると、後で人が見るときに説明が要りません。**理由が3つ重なっているJ-1007とJ-1010から見る、という順番も自然に決まります。

数万件を回す

1件ずつ同期で投げていては終わりません。非同期クライアントで並列化します。

import asyncio
import pandas as pd
from typesafe_sdk import AsyncTypeSafeClient, RetryPolicy, TypeSafeAPIError
CONCURRENCY = 16
RETRY = RetryPolicy(max_retries=4, backoff_initial=0.5, backoff_max=8.0)
async def ask_one(client, sem, row):
state = {"勘定科目": row["account"], "摘要": row["description"], "計上部門": row["dept"]}
async with sem:
try:
res = await client.system_one(state=state, questions=QUESTIONS, retry=RETRY)
except TypeSafeAPIError as e:
return {"voucher_no": row["voucher_no"], "error": type(e).__name__}
a = res.answers
return {
"voucher_no": row["voucher_no"],
"expense_type": a["expense_type"].choice,
"type_conf": a["expense_type"].confidence,
"detail_score": a["description_detail"].score,
"private_doubt": a["private_use_doubt"].noul,
"input_tokens": res.usage.input_tokens,
"error": None,
}
async def judge_all(df: pd.DataFrame) -> pd.DataFrame:
sem = asyncio.Semaphore(CONCURRENCY)
async with AsyncTypeSafeClient() as client:
results = await asyncio.gather(*(ask_one(client, sem, r) for _, r in df.iterrows()))
return pd.DataFrame(results)
res = asyncio.run(judge_all(df))
print("エラー:", res["error"].notna().sum())

押さえておく点が3つあります。

  • **Semaphore で同時実行数を抑える。**レート制限は1分あたり1,200リクエストです。無制限に投げると429が返ります
  • 例外は握って行に残す。asyncio.gather の途中で1件落ちると全体が止まります。失敗した行だけ後で再実行できるようにします
  • **voucher_no を必ず入れる。**順序で突き合わせると、エラー行が混ざったときにずれます

コストの見積もり

課金は入力トークンだけで、100万トークンあたり $0.042 です。出力は無料です。

見落としやすいのは、QUESTIONS の定義が毎リクエストの入力に乗ることです。上の3問は日本語で400字程度あります。摘要が20字でも、1件あたりの入力はほぼ質問定義の分量で決まります。

1件あたり約200トークンとすると、こうなります。

件数 入力トークン 概算コスト
1,000件 20万 $0.008
50,000件 1,000万 $0.42
500,000件 1億 $4.2

50万件でも数百円の水準です。コストより先に、レート制限と処理時間のほうが効いてきます。

uniq = (
df[["account", "description", "dept"]]
.drop_duplicates()
.reset_index(drop=True)
)
uniq["voucher_no"] = [f"U-{i:06d}" for i in range(len(uniq))] # 判定用の一時キー
judged = asyncio.run(judge_all(uniq))
uniq_judged = uniq.merge(judged, on="voucher_no")
merged = df.merge(
uniq_judged.drop(columns=["voucher_no"]),
on=["account", "description", "dept"],
how="left",
)

ask_one が voucher_no を返す作りなので、ユニーク側にも一時キーを振ってから投げます。pd.concat で横に並べるだけだと、エラー行が混ざったときに行がずれます。

監査で使うときの限界

ここは分けて書きます。**Jevの出力は監査証拠ではありません。**手続の対象を絞り込む道具です。

そのうえで、実務で引っかかる点を挙げます。

再現性が保証されない。 同じ入力に対して常に同じ確率が返るとは限りません。判定を手続の根拠として残すなら、**実行日・モデル名(res.model)・質問定義・生の確率値をすべて保存します。**後から再実行しても同じ結果になるとは限らないため、調書には結果そのものを綴じます。

out["model"] = res.model
out["judged_at"] = pd.Timestamp.now()
out.to_csv("judged_2026-03.csv", index=False)

摘要に書かれた文言に引きずられる。 Jevは入力データを疑いません。摘要に指示のような文が入っていれば、それに従ってしまう可能性があります。会計システムの摘要欄は自由入力なので、この経路は現実に存在します。判定結果を無条件に信用せず、confidence の低いものと高すぎるものの両方を見るのが安全です。

データを外部に送ることになる。 摘要欄には取引先名や担当者名が入ります。外部APIに送ることになるので、**社内規程と委託先管理の確認を先に済ませます。**TypeSafeは顧客のリクエストを学習に使わないとしていますが、それとは別に、自社の手続として整理が必要です。

日本語の精度は英語より落ちる。 公式ドキュメントで明示されています。日本語の摘要で使うなら、手元で正解を付けたデータを数百件用意して当たりを確認する工程は省けません。instructions と criteria を英語で書き、state だけ日本語にすると改善する場合があります。

おわりに

仕訳の摘要欄は、これまで「目で見るしかない」領域でした。Jevのような型付きの判断を返すモデルが入ると、数万件を全部見たうえで、説明が必要な数十件に絞るという進め方ができるようになります。

ただし、やっていることは今までと変わりません。

  • 機械的に確定できること(金額、日付、件数)はコードで出す
  • テキストの意味の判断をJevに投げ、確率として受け取る
  • しきい値と最終的な結論は人が決める

Jevが増やしてくれるのは絞り込みの精度であって、判断そのものではありません。監査サンプリングや異常仕訳の検出と同じで、絞り込んだ先を人が見るという構造は変わらないままです。

その構造を保ったまま、絞り込みの網を摘要欄まで広げられるようになった、というのがこのモデルの実務上の意味だと思っています。

この記事を書いた人

barorinのプロフィール画像

barorinCPA & Engineer

会計とITの二足のわらじで働く公認会計士です。Pythonを中心に、会計・監査の実務で使えるコードや、Ubuntu・Docker等の設定メモなどを書いています。