Claude APIで決算書PDFから勘定科目を抽出する — 貸借の検算で審査用スプレッドへ
決算書PDFの貸借対照表・損益計算書を固定の科目マスタに抜き出し、貸借一致と利益の連鎖をコードで検算して、審査用スプレッドに流し込むPythonの手順です。
融資や与信の審査では、取引先から届いた決算書PDFを、自社の様式の財務スプレッドに打ち直す作業が毎回発生します。科目名は会社ごとに微妙に違い、単位は円・千円・百万円が混在し、マイナスは「△」で書かれています。この転記を、Claude APIのPDF対応とstructured outputsで「科目マスタに沿った表」に置き換えるのがこの記事の話です。
流れは4段です。決算書PDFを渡して科目と金額をJSONで受け取り、貸借の一致と利益の連鎖をコードで検算し、前期と当期を整えて、最後に審査用のExcelテンプレートへ書き込みます。チャットに決算書を貼って論点を洗う進め方はClaudeで勘定科目内訳書から実態バランスシートの論点を洗う手順にあります。ここでは、件数が増えても同じ形で回せるようにスクリプトにします。
全体の流れ — Claudeは読み取り、合っているかはコードが見る
決算書PDFがスプレッドに入るまで
- 1
PDFをそのまま渡す
1社1リクエストで、決算書PDFと科目マスタの指示を渡します。
- 2
科目マスタに沿ったJSONで受け取る
貸借対照表と損益計算書を、期ごとの行のリストで返させます。
- 3
コードで検算する
資産合計と負債純資産合計の一致、小計の積み上げ、利益の連鎖を調べます。
- 4
テンプレートに書き込む
単位を千円にそろえ、科目の行・決算期の列へ入れます。
Claudeが担うのは1番目と2番目、つまり「PDFを読んで決まった形に起こす」ところだけです。3番目を意図的にコード側へ置くのは、読み取りで1行落ちたり桁を取り違えたりしたとき、AIの自己申告に頼らず機械的に気づける形にしておくためです。決算書は、貸借が一致するという強い制約を持つ書類です。検算の材料が最初から入っているので、この手順と相性がよい題材です。
PDFの渡し方と、先に確かめておく条件
PDFは、URLの参照、base64で埋め込むdocumentブロック、Files APIのfile_idの3通りで渡せます。取引先から受け取ったファイルを1社ずつ処理するなら、base64で足ります。
PDFを渡すときの上限
リクエストサイズ
32MB
プラットフォームにより異なる
1リクエストのページ数
600
コンテキストが1M未満なら100
形式
標準PDF
パスワード・暗号化は不可
決算書で気をつけたいのは、ページ数よりも中身の混ざり方です。貸借対照表と損益計算書のほかに、株主資本等変動計算書、個別注記表、申告書の別表や勘定科目内訳書が1冊に綴じられていることがあります。細かい表や小さい文字のページが多いPDFは、ページ数の上限に届く前にコンテキストを使い切る場合があり、その対策として、ドキュメントは大きなPDFを区切って送ることを勧めています。審査に使うのが貸借対照表と損益計算書だけなら、その数ページを切り出してから送ると、抽出の精度もコストも安定します。
トークンは、1ページあたりテキスト分で1,500〜3,000が目安で、PDF専用の追加料金はありません。各ページは画像にも変換されて渡され、画像分のトークンも別にかかります。30ページの決算書なら、テキスト分だけで45,000〜90,000トークンです。実際の消費は、token countingで自分のPDFを測るのが確実です。リクエストが通らないときの400エラーは「Could not process PDF」の原因と対処法で切り分けられます。
ページが画像にも変換されるため、文字レイヤーのないスキャンの決算書でも読み取り自体は進みます。ただし画質が低いと桁を読み違えるので、検算が活きるのはこのケースです。送るときは、PDFをテキストより前に置き、ページを上向きにそろえ、文字がはっきり読める状態にします。いずれも公式の改善指針に挙がっている点です。
スキーマの設計 — 科目を「行のリスト」で返させる理由
最初に思いつくのは、「現金預金」「売掛金」「棚卸資産」…と、科目をフィールドとして並べる形です。ただ、この形はstructured outputsの上限に当たりやすくなります。
スキーマの2つの組み方
科目ごとにフィールドを並べる
「なければnull」にした項目は、任意項目やanyOfとして数えられます。任意項目は全体で24個、unionを使う項目は16個が上限です。貸借対照表と損益計算書の科目を34項目並べると、ここに積み上がります。
科目マスタを列挙値にして行のリストで返す
「印字された科目名・マスタ上の科目・金額・ページ」を1行とし、載っている行だけ返させます。nullを使わないので、上限の計算に乗りません。マスタにない科目は「その他」に寄せるルールにします。
上限を超えると、Schema is too complex for compilationという400エラーになります。公式は、任意項目を減らすこと、入れ子を平らにすることを、対処の優先順位として挙げています。行リストの形は、この対処を設計の最初から取り入れたものです。
もう一つ、minimumやmaxLengthのような数値・文字列の制約はスキーマに書けません。金額の妥当性はスキーマに任せず、後段の検算で見ます。
from enum import Enum
from pydantic import BaseModel
BS_ITEMS = [
"現金預金", "売上債権", "棚卸資産", "その他流動資産", "流動資産合計",
"有形固定資産", "無形固定資産", "投資その他の資産", "固定資産合計",
"繰延資産", "資産合計",
"仕入債務", "短期借入金", "その他流動負債", "流動負債合計",
"長期借入金", "その他固定負債", "固定負債合計", "負債合計",
"純資産合計", "負債純資産合計",
]
PL_ITEMS = [
"売上高", "売上原価", "売上総利益", "販売費及び一般管理費", "営業利益",
"営業外収益", "営業外費用", "経常利益", "特別利益", "特別損失",
"税引前当期純利益", "法人税等", "当期純利益",
]
Item = Enum("Item", {n: n for n in BS_ITEMS + PL_ITEMS}, type=str)
Unit = Enum("Unit", {n: n for n in ["円", "千円", "百万円"]}, type=str)
UNIT_YEN = {"円": 1, "千円": 1_000, "百万円": 1_000_000}
class Line(BaseModel):
printed_label: str # 決算書に印字された科目名そのまま
item: Item # 科目マスタ上の科目。小計・合計の行もそのまま
amount: int # 表の単位のまま。△や(-)は負の数で返す
source_page: int # PDFビューアー上のページ番号
class Period(BaseModel):
label: str # 当期 / 前期
year_end: str # 決算期末日 YYYY-MM-DD
balance_sheet: list[Line]
income_statement: list[Line]
class Statement(BaseModel):
company: str
unit: Unit # 表紙または表の見出しに書かれた単位
periods: list[Period]小計と合計の行も、同じLineとして返させるのがポイントです。「流動資産合計」を印字されたまま受け取っておけば、構成科目の足し上げと突き合わせられます。
科目マスタの粒度は、自社の審査様式に合わせて足し引きします。スキーマ定義は最大24時間キャッシュされるため、マスタには取引先名のような固有の情報を入れず、固有の情報はメッセージ側に置きます。
PDFを渡して科目の行を受け取る
SDKのclient.messages.parse()にPydanticのモデルをoutput_formatとして渡すと、SDKがスキーマへ変換し、返ってきたJSONを検証してparsed_outputに入れてくれます。
pip install anthropic pydantic openpyxlimport base64
from pathlib import Path
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5-5"
SYSTEM = """あなたは決算書PDFを財務スプレッドの科目に起こす担当です。
PDFに印字された数字だけを根拠にし、計算で補った数字は返しません。
金額は表の単位のままの整数で返し、△やカッコ付きの金額は負の数にします。
貸倒引当金などの控除項目は、対応する科目に含めてマイナスで返します。
マスタに該当がない科目は「その他流動資産」などその他の科目に寄せます。
ページ番号はPDFビューアー上のページ番号で答えます。"""
PROMPT = (
"貸借対照表と損益計算書の全行をperiodsに入れてください。"
"当期と前期の両方の列がある場合は、2つのPeriodに分けます。"
"小計・合計の行も、印字されている通りに1行として返します。"
)
def extract_statement(pdf_path: Path) -> Statement:
data = base64.standard_b64encode(pdf_path.read_bytes()).decode("utf-8")
response = client.messages.parse(
model=MODEL,
max_tokens=16000,
system=SYSTEM,
messages=[{
"role": "user",
"content": [
{"type": "document",
"source": {"type": "base64",
"media_type": "application/pdf",
"data": data}},
{"type": "text", "text": PROMPT},
],
}],
output_format=Statement,
)
if response.stop_reason != "end_turn":
raise RuntimeError(f"{pdf_path.name}: {response.stop_reason}")
return response.parsed_output最後のstop_reasonの確認は省かないでください。出力がmax_tokensで途切れると、JSONがスキーマに合わない形で終わることがあります。科目の多い決算書はmax_tokensを多めに取ります。安全上の理由で応答を断られたときは、stop_reasonがrefusalになり、ステータスは200のまま返ります。ステータスだけを見ていると気づけません。
システムプロンプトの「計算で補った数字は返しません」は、決算書では特に効きます。合計が足りないと、モデルが善意で足し算して空欄を埋めてしまうことがあり、すると後段の検算が合ってしまい、誰も補完に気づけないからです。
引用機能(citations)は、output_config.formatと併用すると400エラーになります。ページの根拠を引用機能で付ける道は使えないため、source_pageを自前の列として持たせています。
貸借の一致と利益の連鎖を検算する
決算書の数字は、互いに足し算と引き算で縛られています。この縛りを表にしてコードに渡します。
| 検算 | 崩れたときに疑うこと |
|---|---|
| 資産合計=負債純資産合計 | 崩れたときに疑うこと行の抜け、期の取り違え、単位の取り違え |
| 小計=構成科目の合計 | 崩れたときに疑うこと科目の分類違い、控除項目の符号 |
| 売上総利益から当期純利益までの連鎖 | 崩れたときに疑うこと販管費や営業外の行の抜け、費用の符号 |
from collections import defaultdict
RULES = {
"流動資産合計": [(1, "現金預金"), (1, "売上債権"), (1, "棚卸資産"),
(1, "その他流動資産")],
"固定資産合計": [(1, "有形固定資産"), (1, "無形固定資産"),
(1, "投資その他の資産")],
"資産合計": [(1, "流動資産合計"), (1, "固定資産合計"), (1, "繰延資産")],
"流動負債合計": [(1, "仕入債務"), (1, "短期借入金"), (1, "その他流動負債")],
"固定負債合計": [(1, "長期借入金"), (1, "その他固定負債")],
"負債合計": [(1, "流動負債合計"), (1, "固定負債合計")],
"負債純資産合計": [(1, "負債合計"), (1, "純資産合計")],
"売上総利益": [(1, "売上高"), (-1, "売上原価")],
"営業利益": [(1, "売上総利益"), (-1, "販売費及び一般管理費")],
"経常利益": [(1, "営業利益"), (1, "営業外収益"), (-1, "営業外費用")],
"税引前当期純利益": [(1, "経常利益"), (1, "特別利益"), (-1, "特別損失")],
"当期純利益": [(1, "税引前当期純利益"), (-1, "法人税等")],
}
def sum_by_item(lines) -> dict[str, int]:
d: dict[str, int] = defaultdict(int)
for ln in lines:
d[ln.item.value] += ln.amount
return d
def verify(st: Statement) -> list[str]:
problems = []
for p in st.periods:
v = sum_by_item(p.balance_sheet + p.income_statement)
for total, terms in RULES.items():
if total not in v:
continue
calc = sum(sign * v.get(name, 0) for sign, name in terms)
tol = len(terms) # 端数処理の幅。構成科目1つにつき1単位
if abs(calc - v[total]) > tol:
problems.append(
f"{p.label} {total}: 印字 {v[total]:,} / 積み上げ {calc:,}")
if v.get("資産合計") != v.get("負債純資産合計"):
problems.append(
f"{p.label}: 資産合計 {v.get('資産合計')} と"
f"負債純資産合計 {v.get('負債純資産合計')} が不一致")
return problemstolは端数処理の幅です。千円や百万円の単位で表示された決算書は、各科目を丸めて表示するため、構成科目の足し上げと合計が数単位ずれることがあります。1円単位のぴったり一致を求めると誤検知が増えるので、構成科目の数に応じた幅を持たせています。一方、貸借の一致は丸めても崩れないよう作られている書類なので、ここは完全一致で見ています。会社によって合わないときは、その会社だけ幅を広げます。
検算が崩れたときは、source_pageのページをPDFビューアーで開きます。検算が合わなかったから誤りと決めつけず、「人が見るべき行の絞り込み」として使うのが現実的です。逆に、検算が全部通っても、科目の分類違いまでは守れません。たとえば、貸付金を「その他流動資産」に入れるか「投資その他の資産」に入れるかは、合計が同じなので検算では分かりません。
審査用スプレッドに流し込む
検算を通った結果を、自社のExcelテンプレートに書き込みます。テンプレートは、A列に科目名、1行目に決算期を置く形を想定しています。単位は千円にそろえます。
from openpyxl import load_workbook
def collect(statements: list[Statement]):
"""決算期末日 -> {科目: 千円}。前期列と当期列の食い違いも拾う。"""
by_end: dict[str, dict[str, int]] = {}
diffs = []
for st in statements:
factor = UNIT_YEN[st.unit.value]
for p in st.periods:
v = sum_by_item(p.balance_sheet + p.income_statement)
vals = {k: round(a * factor / 1000) for k, a in v.items()}
if p.year_end in by_end:
for k, a in vals.items():
old = by_end[p.year_end].get(k)
if old is not None and abs(old - a) > 1:
diffs.append(f"{p.year_end} {k}: {old:,} → {a:,}")
by_end.setdefault(p.year_end, {}).update(vals)
return by_end, diffs
def write_spread(statements, template, out):
by_end, diffs = collect(statements)
wb = load_workbook(template)
ws = wb["スプレッド"]
rows = {ws.cell(r, 1).value: r for r in range(2, ws.max_row + 1)}
for col, end in enumerate(sorted(by_end), start=2):
ws.cell(1, col, end)
for item, amount in by_end[end].items():
if item not in rows:
raise KeyError(f"テンプレートに科目の行がありません: {item}")
ws.cell(rows[item], col, amount)
wb.save(out)
return diffscollectでは、同じ決算期末日が2つの決算書に現れたとき(新しい決算書の前期列と、古い決算書の当期列)の金額を突き合わせています。前期の数字が後の決算書で組み替えられていると、ここに差が出ます。差が出た科目は、どちらの決算書の数字を採用するか、審査担当者が決める項目です。コードでは新しい決算書の値で上書きしたうえで、差分を一覧にして返しています。
3期分の決算書を順に回す呼び出しは、次のようになります。
statements = []
for pdf in sorted(Path("kessansho").glob("*.pdf")):
st = extract_statement(pdf)
for msg in verify(st):
print(f"[要確認] {pdf.name}: {msg}")
statements.append(st)
for d in write_spread(statements, "spread_template.xlsx", "spread_out.xlsx"):
print(f"[期間の食い違い] {d}")つまずきやすい点
落とし穴
単位を取り違えて桁がずれる
表紙には「単位:千円」と書かれ、本文の表では単位が省かれている決算書があります。unitを読み違えると、貸借の一致は崩れないまま全科目が1,000倍ずれます。検算では気づけないため、売上高や資産合計が、取引先の規模から見て妥当な桁かを、スプレッドを開いた人が最初に見る運用にします。
- 前期と当期の列を取り違える: 左が当期とは限りません。
labelとyear_endを両方返させ、year_endが新しいほうを当期として扱うよう、書き込み側でそろえておくと安全です - 「その他」に落ちる科目が多すぎる: マスタが粗いと、分類のばらつきが後の分析に響きます。
printed_labelを別シートに残しておけば、どの印字がどの科目に寄せられたかを後から見直せます - 申告書の別表を読んでしまう: PDFに別表が綴じられていると、貸借対照表の数字と別表の数字が混ざります。プロンプトで対象を「貸借対照表と損益計算書」に限り、PDFの側でも該当ページだけを送ります
- 同じスキーマでも初回だけ遅い: スキーマを初めて使うときは、文法の準備のために追加の遅延があり、準備した結果は最後の利用から24時間キャッシュされます。科目マスタを頻繁に書き換えながら試すと、そのたびに初回の遅さが出ます
- 列挙値の大文字小文字: structured outputsは、列挙値の大文字小文字までは保証されません。日本語の科目名では起きにくい一方、「EBITDA」のような英字の科目をマスタに足すなら、照合を大文字小文字で区別しない形にします
件数が増えたとき
数社分なら、上のスクリプトで足ります。月に数百社を処理するなら、Message Batches APIでPDFを1件1リクエストとして投げる方法があります。structured outputsはバッチ処理と併用でき、公式はバッチ処理に50%の割引があると説明しています。その手順は契約台帳をPDFから一括抽出する手順にあり、この記事のスキーマと検算を差し替えれば流用できます。
同じシステムプロンプトとスキーマを繰り返し使う運用では、プロンプトキャッシュの無効化にも注意が必要です。output_config.formatを変えると、その会話のプロンプトキャッシュは無効になります。
上場企業の有価証券報告書なら、PDFでなく提出書類のデータから取る入口もあります。手順はEDINET APIで有価証券報告書をClaudeに読ませるにあります。
本番で使う前の線引き
この手順が出すのは「検算つきの下書き」です。貸借が一致し、利益の連鎖が合っていても、科目の分類と、前期の組み替えの採否は人が決めます。審査の判断そのものを、抽出結果から自動で出す構成にはしません。
最初の数社は、source_pageのページを開いて、PDFとスプレッドを目で突き合わせる運用にすると、プロンプトとマスタの調整点が見えてきます。3期分の決算書から指標を作って所見まで書く流れはローカルベンチマークの財務分析を決算書3期分からClaudeで作る手順に続けられます。見積書のように、金額が互いに縛られる別の書類を同じ作りで扱った例は見積書PDFをClaude APIで品目表に構造化する手順にあります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。